优化目录结构

This commit is contained in:
2026-05-25 20:37:36 +08:00
parent bbe7e71274
commit 40fd7f7e08
101 changed files with 108 additions and 110 deletions

View File

@@ -0,0 +1,251 @@
import { createLogger } from '../../shared/logger.js';
const logger = createLogger('chat');
/**
* 消息模块
* 处理聊天消息的发送、接收和显示
*/
import { showNotification, generateId } from '../../shared/utils.js';
import store from '../store.js';
import { mockMessages } from '../models.js';
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
// 消息相关的状态管理方法
let messageState = {
messages: [...mockMessages],
unreadCount: 0,
isSidebarOpen: false
};
let listeners = [];
/**
* 订阅状态变化
* @param {Function} callback - 回调函数
* @returns {Function} 取消订阅的函数
*/
function subscribe(callback) {
listeners.push(callback);
return () => {
listeners = listeners.filter(cb => cb !== callback);
};
}
/**
* 通知所有监听器
* @param {Object} changes - 变化对象
*/
function notify(changes) {
listeners.forEach(cb => cb(messageState, changes));
}
/**
* 添加消息
* @param {Object} message - 消息对象
*/
function addMessage (message) {
messageState.messages.push(message);
// 如果侧边栏关闭且不是自己发的,增加未读
if (!messageState.isSidebarOpen && !message.isSelf) {
messageState.unreadCount++;
notify({ type: 'SIDEBAR_TOGGLE', unreadCount: messageState.unreadCount });
}
notify({ type: 'NEW_MESSAGE', message, unreadCount: messageState.unreadCount });
}
/**
* 发送聊天消息
* @param {Object} message - 消息对象
* @param {Object} renderstreaming - WebRTC连接管理实例
*/
function sendChatMessage(message) {
if (store.getRenderStreaming()) {
store.getRenderStreaming().sendMessage({
type: 'chat-message',
data: message,
});
}
}
/**
* 处理接收到的聊天消息
* @param {Object} data - 消息数据
*/
function handleChatMessage(data) {
logger.debug('处理聊天:', data);
addMessage(data);
const isImage = data.content && data.content.startsWith('data:image/');
// 显示通知
if (!data.isSelf) {
const content = isImage ? '[图片]' : data.content;
showNotification(`${data.senderName}: ${content.substring(0, 20)}${content.length > 20 ? '...' : ''}`);
}
}
/**
* 切换侧边栏
* @returns {boolean} 切换后的状态
*/
function toggleSidebar() {
messageState.isSidebarOpen = !messageState.isSidebarOpen;
if (messageState.isSidebarOpen) {
messageState.unreadCount = 0;
}
notify({ type: 'SIDEBAR_TOGGLE', isOpen: messageState.isSidebarOpen, unreadCount: messageState.unreadCount });
return messageState.isSidebarOpen;
}
/**
* 获取消息状态
* @returns {Object} 消息状态
*/
function getMessageState() {
return messageState;
}
/**
* 发送消息
*/
function sendMessage() {
const chatInput = document.getElementById('chatInput');
const content = chatInput.value.trim();
if (content) {
const state = store.getState();
const message = {
id: generateId(),
senderId: state.session.localUser.id,
senderName: state.session.localUser.name,
senderAvatar: state.session.localUser.avatar,
content: content,
type: 'text',
timestamp: new Date().toISOString(),
isSelf: true
};
addMessage(message);
const newMessage = { ...message };
newMessage.isSelf = false;
chatInput.value = '';
// 发送消息到服务器
sendChatMessage(newMessage);
//wsManager.send('chat-message', message);
}
}
/**
* 处理聊天输入回车
* @param {KeyboardEvent} event - 键盘事件
*/
function handleChatSubmit(event) {
if (event.key === 'Enter') {
sendMessage();
}
}
/**
* 打开图片选择器
*/
function openImagePicker() {
document.getElementById('imageInput').click();
}
/**
* 处理图片上传
* @param {Event} event - 事件对象
*/
function handleImageUpload(event) {
const file = event.target.files[0];
if (file) {
// 检查文件类型
if (!file.type.startsWith('image/')) {
showNotification('请选择图片文件', 3000);
return;
}
// 检查文件大小限制为5MB
if (file.size > MAX_IMAGE_SIZE) {
showNotification('图片文件不能超过5MB', 3000);
return;
}
// 读取图片文件
const reader = new FileReader();
reader.onload = function (e) {
const imageUrl = e.target.result;
sendImageMessage(imageUrl, file.name);
};
reader.readAsDataURL(file);
// 重置文件输入
event.target.value = '';
}
}
/**
* 发送图片消息
* @param {string} imageUrl - 图片URL
* @param {string} fileName - 文件名
*/
function sendImageMessage(imageUrl, fileName) {
const state = store.getState();
const newMessage = {
id: generateId(),
senderId: state.session.localUser.id,
senderName: state.session.localUser.name,
senderAvatar: state.session.localUser.avatar,
content: imageUrl,
fileName: fileName,
type: 'file',
timestamp: new Date().toISOString(),
isSelf: true
};
// 添加消息到本地列表
addMessage(newMessage);
// 发送消息到服务器
const messageToSend = { ...newMessage };
messageToSend.isSelf = false;
sendChatMessage(messageToSend);
}
/**
* 绑定消息相关的DOM事件
*/
function bindMessageEvents() {
// 发送消息
window.sendMessage = sendMessage;
// 处理聊天输入回车
window.handleChatSubmit = handleChatSubmit;
// 打开图片选择器
window.openImagePicker = openImagePicker;
// 处理图片上传
window.handleImageUpload = handleImageUpload;
}
// 导出所有函数
export default {
sendMessage,
handleChatSubmit,
openImagePicker,
handleImageUpload,
sendImageMessage,
bindMessageEvents,
addMessage,
sendChatMessage,
handleChatMessage,
toggleSidebar,
getMessageState,
subscribe
};

View File

@@ -0,0 +1,61 @@
export function createMessageElement(message, formatTimestamp) {
const messageDiv = document.createElement('div');
let messageClass = 'chat-bubble';
if (message.type === 'system') {
messageClass += ' message-system';
} else if (message.isSelf) {
messageClass += ' message-self';
} else {
messageClass += ' message-other';
}
messageDiv.className = messageClass;
messageDiv.dataset.messageId = message.id;
const contentHTML = message.type === 'file' && message.content.startsWith('data:image/')
? `
<div class="message-image-container">
<img src="${message.content}" class="message-image" alt="${message.fileName || '\u56fe\u7247'}">
${message.fileName ? `<div class="message-image-name">${message.fileName}</div>` : ''}
</div>
`
: `
<div class="message-text">
${message.content}
</div>
`;
messageDiv.innerHTML = `
<div class="message-header">
<img src="${message.senderAvatar}" class="message-avatar">
<div>
<span class="message-sender">${message.senderName}</span>
<span class="message-time">${formatTimestamp(message.timestamp)}</span>
</div>
</div>
<div class="message-content">
${contentHTML}
</div>
`;
return messageDiv;
}
export function renderChatMessagesInto(container, messages, formatTimestamp) {
if (!container) return;
container.innerHTML = '';
const startTimeElement = document.createElement('div');
startTimeElement.className = 'text-center text-xs text-gray-500 my-4';
const startTime = messages[0]?.timestamp || new Date().toISOString();
startTimeElement.textContent = `\u901a\u8bdd\u5f00\u59cb ${formatTimestamp(startTime)}`;
container.appendChild(startTimeElement);
messages.forEach(message => {
container.appendChild(createMessageElement(message, formatTimestamp));
});
container.scrollTop = container.scrollHeight;
}

View File

@@ -0,0 +1,211 @@
import { showNotification } from '../shared/utils.js';
import store from './store.js';
import {
fetchConnectionDirectory,
fetchOnlineUsers,
renderConnectionIds,
renderOnlineUsers
} from './signaling/connect-directory.js';
import { createProfileSettingsController } from './controllers/profile-settings.js';
import { createLogger } from '../shared/logger.js';
const logger = createLogger('connectview');
let onWsStatusChange = null;
let cachedOnlineUsers = [];
const profileSettingsController = createProfileSettingsController({
store,
notify: showNotification
});
export function setWsStatusCallback(callback) {
onWsStatusChange = callback;
}
export function updateWsStatus(connected) {
const wsStatusDot = document.getElementById('wsStatusDot');
const wsStatusText = document.getElementById('wsStatusText');
if (wsStatusDot && wsStatusText) {
if (connected) {
wsStatusDot.className = 'w-2 h-2 bg-green-500 rounded-full animate-pulse';
wsStatusText.textContent = 'WebSocket已连接';
} else {
wsStatusDot.className = 'w-2 h-2 bg-gray-500 rounded-full';
wsStatusText.textContent = '未连接';
}
}
if (onWsStatusChange) {
onWsStatusChange(connected);
}
}
export async function initWebSocket() {
try {
await store.connectSignaling();
store.syncSocketUserInfo();
updateWsStatus(true);
await refreshOnlineUsers();
logger.debug('WebSocket initialized from connectview');
} catch (error) {
logger.error('Failed to initialize WebSocket:', error);
updateWsStatus(false);
showNotification('WebSocket连接失败请刷新页面重试', 'error');
}
}
async function refreshOnlineUsers(silent = true) {
try {
cachedOnlineUsers = await fetchOnlineUsers();
updateOnlineUsersList(cachedOnlineUsers);
if (!silent) {
showNotification(`当前共有 ${cachedOnlineUsers.length} 个WebSocket用户在线`);
}
} catch (error) {
logger.error('Error fetching online users:', error);
if (!silent) {
showNotification('获取在线用户失败', 'error');
}
}
}
async function getAllConnectionIds() {
showNotification('正在获取连接ID和在线用户...');
try {
const { connectionIds, users } = await fetchConnectionDirectory();
cachedOnlineUsers = users;
updateConnectionIdList(connectionIds);
updateOnlineUsersList(cachedOnlineUsers);
} catch (error) {
logger.error('Error fetching connection IDs:', error);
showNotification('获取连接信息失败', 'error');
}
}
function updateConnectionIdList(connectionIds) {
const idsContainer = document.getElementById('idsContainer');
const connectionIdsList = document.getElementById('connectionIdsList');
renderConnectionIds({
connectionIds,
idsContainer,
connectionIdsList,
onSelectConnectionId: selectConnectionId
});
if (idsContainer) {
showNotification(`找到 ${connectionIds.length} 个连接ID`);
}
}
function getCurrentUserId() {
try {
const settings = JSON.parse(localStorage.getItem('userSettings') || '{}');
return settings.userId || settings.id || '';
} catch (error) {
logger.error('Error parsing current user settings:', error);
return '';
}
}
function updateOnlineUsersList(users) {
const onlineUsersList = document.getElementById('onlineUsersList');
const usersContainer = document.getElementById('usersContainer');
const onlineUsersSummary = document.getElementById('onlineUsersSummary');
renderOnlineUsers({
users,
currentUserId: getCurrentUserId(),
onlineUsersList,
usersContainer,
onlineUsersSummary
});
}
function selectConnectionId(connectionId) {
const connectionIdInput = document.getElementById('connectionIdInput');
if (connectionIdInput) {
connectionIdInput.value = connectionId;
showNotification(`已选择连接ID: ${connectionId}`);
}
}
export function loadUserSettings() {
profileSettingsController.loadUserSettings();
}
export function saveSettings() {
profileSettingsController.saveSettings();
}
export function handleAvatarUpload(event) {
profileSettingsController.handleAvatarUpload(event);
}
export function copyUserId() {
profileSettingsController.copyUserId();
}
export function toggleSettingsMenu() {
profileSettingsController.toggleSettingsMenu();
}
export function bindConnectViewEvents(onJoinCall, onCreateCall) {
const connectBtn = document.getElementById('connectBtn');
if (connectBtn && !connectBtn.dataset.bound) {
connectBtn.addEventListener('click', () => {
const connectionIdInput = document.getElementById('connectionIdInput');
const connectionId = connectionIdInput ? connectionIdInput.value.trim() : '';
if (connectionId) {
onJoinCall(connectionId);
} else {
showNotification('请输入连接ID', 'error');
}
});
connectBtn.dataset.bound = 'true';
}
const createCallBtn = document.getElementById('createCallBtn');
if (createCallBtn && !createCallBtn.dataset.bound) {
createCallBtn.addEventListener('click', onCreateCall);
createCallBtn.dataset.bound = 'true';
}
const browseIdsBtn = document.getElementById('browseIdsBtn');
if (browseIdsBtn && !browseIdsBtn.dataset.bound) {
browseIdsBtn.addEventListener('click', getAllConnectionIds);
browseIdsBtn.dataset.bound = 'true';
}
const connectionIdInput = document.getElementById('connectionIdInput');
if (connectionIdInput && !connectionIdInput.dataset.bound) {
connectionIdInput.addEventListener('keypress', (event) => {
if (event.key !== 'Enter') {
return;
}
const connectionId = connectionIdInput.value.trim();
if (connectionId) {
onJoinCall(connectionId);
} else {
showNotification('请输入连接ID', 'error');
}
});
connectionIdInput.dataset.bound = 'true';
}
const userSettingsBtn = document.getElementById('userSettingsBtn');
if (userSettingsBtn && !userSettingsBtn.dataset.bound) {
userSettingsBtn.addEventListener('click', toggleSettingsMenu);
userSettingsBtn.dataset.bound = 'true';
}
}
profileSettingsController.bindDocumentEvents();
profileSettingsController.bindWindowHandlers();
window.selectConnectionId = selectConnectionId;

View File

@@ -0,0 +1,136 @@
export function createCallViewController({ store, chatMessage, notify }) {
let isBound = false;
function toggleSidebar() {
chatMessage.toggleSidebar();
}
function toggleMute() {
const state = store.getState();
const currentState = state.session.localUser.mediaState.audio;
store.updateLocalMedia('audio', !currentState);
}
function toggleVideo() {
const state = store.getState();
const currentState = state.session.localUser.mediaState.video;
store.updateLocalMedia('video', !currentState);
}
function toggleLocalVideo() {
toggleVideo();
}
async function toggleRecording() {
try {
const result = await store.toggleRecording();
notify(result.message);
}
catch (error) {
notify(error.message || '\u5f55\u5236\u5931\u8d25');
}
}
function toggleMoreOptions() {
const menu = document.getElementById('moreOptionsMenu');
if (menu) {
menu.classList.toggle('hidden');
}
}
function changeResolution(width, height) {
store.changeResolution(width, height);
const menu = document.getElementById('moreOptionsMenu');
if (menu) {
menu.classList.add('hidden');
}
}
function endCall() {
const dialog = document.getElementById('endCallDialog');
if (dialog) {
dialog.classList.remove('hidden');
}
}
function cancelEndCall() {
const dialog = document.getElementById('endCallDialog');
if (dialog) {
dialog.classList.add('hidden');
}
}
function confirmEndCall() {
cancelEndCall();
store.endCall();
notify('\u901a\u8bdd\u5df2\u7ed3\u675f');
}
function handleKeydown(event) {
if (event.code === 'Space' && !event.target.matches('input, textarea')) {
event.preventDefault();
toggleMute();
}
if (event.ctrlKey && event.key === 'v') {
event.preventDefault();
toggleVideo();
}
}
function handleDocumentClick(event) {
const moreOptionsMenu = document.getElementById('moreOptionsMenu');
const moreOptionsButton = document.getElementById('moreOptionsBtn');
if (
moreOptionsMenu &&
moreOptionsButton &&
!moreOptionsMenu.contains(event.target) &&
!moreOptionsButton.contains(event.target)
) {
moreOptionsMenu.classList.add('hidden');
}
}
function bindButton(buttonId, handler) {
const button = document.getElementById(buttonId);
if (button && !button.dataset.bound) {
button.addEventListener('click', handler);
button.dataset.bound = 'true';
}
}
function exposeWindowHandlers() {
window.toggleSidebar = toggleSidebar;
window.toggleMute = toggleMute;
window.toggleVideo = toggleVideo;
window.toggleLocalVideo = toggleLocalVideo;
window.toggleRecording = toggleRecording;
window.toggleMoreOptions = toggleMoreOptions;
window.changeResolution = changeResolution;
window.endCall = endCall;
window.cancelEndCall = cancelEndCall;
window.confirmEndCall = confirmEndCall;
}
function bindDomEvents() {
exposeWindowHandlers();
if (isBound) {
return;
}
chatMessage.bindMessageEvents();
document.addEventListener('keydown', handleKeydown);
document.addEventListener('click', handleDocumentClick);
bindButton('cancelEndCall', cancelEndCall);
bindButton('confirmEndCall', confirmEndCall);
bindButton('moreOptionsBtn', toggleMoreOptions);
isBound = true;
}
return {
bindDomEvents
};
}

View File

@@ -0,0 +1,190 @@
import { createLogger } from '../../shared/logger.js';
const logger = createLogger('invite');
const DEFAULT_CALLER_NAME = '\u9080\u8bf7\u65b9';
const DEFAULT_CALLER_AVATAR = '/images/p2.png';
const DEFAULT_APPLY_REASON = '\u672a\u586b\u5199';
function readConnectionIdFromSearch(search) {
return new URLSearchParams(search).get('connectionId') || '';
}
function hideInviteDialog() {
const dialog = document.getElementById('callRequestDialog');
if (dialog) {
dialog.classList.add('hidden');
}
}
function normalizeInviteCaller(caller = {}) {
return {
connectionId: caller.connectionId || '',
inviterSocketId: caller.inviterSocketId || '',
inviterUserId: caller.inviterUserId || '',
name: caller.name || caller.inviterName || DEFAULT_CALLER_NAME,
avatar: caller.avatar || caller.inviterAvatar || DEFAULT_CALLER_AVATAR,
applyReason: caller.applyReason || caller.reason || DEFAULT_APPLY_REASON
};
}
function renderInviteDialog(caller) {
const dialog = document.getElementById('callRequestDialog');
if (!dialog) {
return false;
}
const callRequestName = document.getElementById('callRequestName');
const callRequestAvatar = document.getElementById('callRequestAvatar');
const callRequestText = document.getElementById('callRequestText');
const callRequestReason = document.getElementById('callRequestReason');
if (callRequestName) {
callRequestName.textContent = caller.name;
}
if (callRequestAvatar) {
callRequestAvatar.src = caller.avatar;
}
if (callRequestText) {
callRequestText.textContent = caller.connectionId
? `\u6b63\u5728\u9080\u8bf7\u60a8\u52a0\u5165\u901a\u8bdd (${caller.connectionId})`
: '\u6b63\u5728\u8bf7\u6c42\u4e0e\u60a8\u8fdb\u884c\u89c6\u9891\u901a\u8bdd';
}
if (callRequestReason) {
callRequestReason.textContent = caller.applyReason;
}
dialog.classList.remove('hidden');
return true;
}
export function createInviteController({
store,
notify,
onAcceptConnection,
getCurrentView,
getConnectionId,
setConnectionId
}) {
let pendingInvite = null;
let signalHandlersBound = false;
function showCallRequestDialog(caller = {}) {
const normalizedCaller = normalizeInviteCaller(caller);
pendingInvite = normalizedCaller;
if (normalizedCaller.connectionId) {
setConnectionId(normalizedCaller.connectionId);
}
return renderInviteDialog(normalizedCaller);
}
function getInvitePayloadFromUrl(search = window.location.search) {
const params = new URLSearchParams(search);
if (params.get('invite') !== '1') {
return null;
}
return normalizeInviteCaller({
name: params.get('callerName'),
avatar: params.get('callerAvatar'),
connectionId: params.get('connectionId')
});
}
function bindSignalHandlers() {
if (signalHandlersBound) {
return;
}
store.onSocketEvent('invite-call', (payload) => {
const caller = normalizeInviteCaller(payload);
showCallRequestDialog(caller);
notify(`${caller.name} \u9080\u8bf7\u4f60\u52a0\u5165\u901a\u8bdd`);
});
signalHandlersBound = true;
}
async function acceptInvite() {
hideInviteDialog();
const targetConnectionId =
(pendingInvite && pendingInvite.connectionId) ||
getConnectionId() ||
localStorage.getItem('connectionId') ||
readConnectionIdFromSearch(window.location.search);
if (!targetConnectionId) {
notify('\u7f3a\u5c11\u8fde\u63a5ID\uff0c\u65e0\u6cd5\u63a5\u53d7\u9080\u8bf7', 'error');
return;
}
setConnectionId(targetConnectionId);
if (pendingInvite) {
try {
store.sendInviteAccepted({
connectionId: targetConnectionId,
targetSocketId: pendingInvite.inviterSocketId,
targetUserId: pendingInvite.inviterUserId
});
} catch (error) {
logger.error('Error accepting invite:', error);
notify('\u63a5\u53d7\u9080\u8bf7\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5', 'error');
return;
}
}
pendingInvite = null;
notify('\u5df2\u63a5\u53d7\u901a\u8bdd\u8bf7\u6c42');
if (getCurrentView() !== 'call') {
await onAcceptConnection(targetConnectionId);
}
}
function rejectInvite() {
hideInviteDialog();
if (pendingInvite) {
try {
store.sendInviteRejected({
connectionId: pendingInvite.connectionId,
targetSocketId: pendingInvite.inviterSocketId,
targetUserId: pendingInvite.inviterUserId
});
} catch (error) {
logger.error('Error rejecting invite:', error);
}
}
pendingInvite = null;
notify('\u5df2\u62d2\u7edd\u901a\u8bdd\u8bf7\u6c42');
}
function bindDialogEvents() {
window.showCallRequest = showCallRequestDialog;
window.rejectCall = rejectInvite;
window.acceptCall = acceptInvite;
const rejectCallButton = document.getElementById('rejectCall');
const acceptCallButton = document.getElementById('acceptCall');
if (rejectCallButton && !rejectCallButton.dataset.bound) {
rejectCallButton.addEventListener('click', rejectInvite);
rejectCallButton.dataset.bound = 'true';
}
if (acceptCallButton && !acceptCallButton.dataset.bound) {
acceptCallButton.addEventListener('click', acceptInvite);
acceptCallButton.dataset.bound = 'true';
}
}
return {
bindDialogEvents,
bindSignalHandlers,
getInvitePayloadFromUrl,
showCallRequestDialog
};
}

View File

@@ -0,0 +1,220 @@
import { createLogger } from '../../shared/logger.js';
const logger = createLogger('profile');
const DEFAULT_AVATAR = '/images/p1.png';
const MAX_AVATAR_SIZE = 2 * 1024 * 1024;
const USER_ID_PREFIX = 'user_';
const USER_ID_LENGTH = 8;
const USER_ID_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
function getElement(id) {
return document.getElementById(id);
}
function setAvatarPreview(avatarUrl) {
const userAvatar = getElement('userAvatar');
const avatarPreview = getElement('avatarPreview');
if (userAvatar) {
userAvatar.src = avatarUrl;
}
if (avatarPreview) {
avatarPreview.src = avatarUrl;
}
}
function updateUserName(name) {
const userName = getElement('userName');
if (userName) {
userName.textContent = name;
}
}
function generateUserId() {
let result = USER_ID_PREFIX;
for (let i = 0; i < USER_ID_LENGTH; i++) {
result += USER_ID_CHARS.charAt(Math.floor(Math.random() * USER_ID_CHARS.length));
}
return result;
}
function readStoredSettings() {
const rawSettings = localStorage.getItem('userSettings');
if (!rawSettings) {
return null;
}
return JSON.parse(rawSettings);
}
function getCurrentSettingsPayload() {
const nicknameInput = getElement('nicknameInput');
const userIdInput = getElement('userIdInput');
const avatarPreview = getElement('avatarPreview');
return {
userId: userIdInput ? userIdInput.value : generateUserId(),
name: nicknameInput ? (nicknameInput.value || '\u6211') : '\u6211',
avatar: avatarPreview ? (avatarPreview.src || DEFAULT_AVATAR) : DEFAULT_AVATAR
};
}
async function uploadAvatar(formData) {
const response = await fetch('/api/upload/avatar', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('\u4e0a\u4f20\u5931\u8d25');
}
return response.json();
}
export function createProfileSettingsController({ store, notify }) {
let documentEventsBound = false;
function loadUserSettings() {
try {
const settings = readStoredSettings();
if (!settings) {
const nextUserId = generateUserId();
const userIdInput = getElement('userIdInput');
if (userIdInput) {
userIdInput.value = nextUserId;
}
setAvatarPreview(DEFAULT_AVATAR);
saveSettings();
return;
}
const userIdInput = getElement('userIdInput');
const nicknameInput = getElement('nicknameInput');
if (settings.userId && userIdInput) {
userIdInput.value = settings.userId;
}
if (settings.name && nicknameInput) {
nicknameInput.value = settings.name;
}
updateUserName(settings.name || '\u6211');
setAvatarPreview(settings.avatar || DEFAULT_AVATAR);
} catch (error) {
logger.error('Error loading user settings:', error);
setAvatarPreview(DEFAULT_AVATAR);
}
}
function saveSettings() {
const settings = getCurrentSettingsPayload();
localStorage.setItem('userSettings', JSON.stringify(settings));
store.syncSocketUserInfo(settings);
updateUserName(settings.name);
setAvatarPreview(settings.avatar);
notify('\u8bbe\u7f6e\u5df2\u4fdd\u5b58', 'success');
}
async function handleAvatarUpload(event) {
const file = event.target.files[0];
if (!file) {
return;
}
if (!file.type.startsWith('image/')) {
notify('\u8bf7\u9009\u62e9\u56fe\u7247\u6587\u4ef6', 'error');
return;
}
if (file.size > MAX_AVATAR_SIZE) {
notify('\u56fe\u7247\u5927\u5c0f\u4e0d\u80fd\u8d85\u8fc72MB', 'error');
return;
}
const formData = new FormData();
formData.append('avatar', file);
const userIdInput = getElement('userIdInput');
if (userIdInput) {
formData.append('userId', userIdInput.value);
}
notify('\u6b63\u5728\u4e0a\u4f20\u5934\u50cf...');
try {
const data = await uploadAvatar(formData);
if (!data.success || !data.avatarUrl) {
throw new Error(data.message || '\u672a\u77e5\u9519\u8bef');
}
setAvatarPreview(data.avatarUrl);
saveSettings();
notify('\u5934\u50cf\u4e0a\u4f20\u6210\u529f', 'success');
} catch (error) {
logger.error('Error uploading avatar:', error);
setAvatarPreview(DEFAULT_AVATAR);
notify('\u5934\u50cf\u4e0a\u4f20\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5', 'error');
}
}
function copyUserId() {
const userIdInput = getElement('userIdInput');
if (!userIdInput) {
return;
}
userIdInput.select();
document.execCommand('copy');
notify('\u7528\u6237ID\u5df2\u590d\u5236\u5230\u526a\u8d34\u677f', 'success');
}
function toggleSettingsMenu() {
const settingsMenu = getElement('settingsMenu');
if (settingsMenu) {
settingsMenu.classList.toggle('hidden');
}
}
function bindDocumentEvents() {
if (documentEventsBound) {
return;
}
document.addEventListener('click', (event) => {
const settingsMenu = getElement('settingsMenu');
const userSettingsButton = getElement('userSettingsBtn');
if (
settingsMenu &&
userSettingsButton &&
!settingsMenu.contains(event.target) &&
!userSettingsButton.contains(event.target)
) {
settingsMenu.classList.add('hidden');
}
});
documentEventsBound = true;
}
function bindWindowHandlers() {
window.saveSettings = saveSettings;
window.handleAvatarUpload = handleAvatarUpload;
window.copyUserId = copyUserId;
window.toggleSettingsMenu = toggleSettingsMenu;
}
return {
bindDocumentEvents,
bindWindowHandlers,
copyUserId,
handleAvatarUpload,
loadUserSettings,
saveSettings,
toggleSettingsMenu
};
}

View File

@@ -0,0 +1,735 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VideoCall - 一对一视频通话</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link rel="stylesheet" href="/styles/style.css">
</head>
<body class="h-screen w-screen flex flex-col text-white bg-grid relative">
<!--
============================================================
connect视图初始连接界面输入连接ID、创建/加入通话)
WebSocket在此视图建立连接
============================================================
-->
<div id="connectView" class="h-full w-full flex flex-col">
<!-- 用户设置区域 -->
<div class="absolute top-4 right-4 z-10">
<button id="userSettingsBtn" class="flex items-center gap-2 glass px-3 py-2 rounded-full hover:bg-white/10 transition-colors">
<img id="userAvatar" src="/images/p1.png" class="w-8 h-8 rounded-full object-cover">
<span id="userName" class="text-sm font-medium"></span>
<i class="fas fa-chevron-down text-xs text-gray-400"></i>
</button>
<!-- 设置菜单 -->
<div id="settingsMenu" class="hidden absolute top-full right-0 mt-2 glass rounded-xl shadow-lg w-48 z-20">
<div class="p-4 border-b border-white/10">
<h3 class="text-sm font-medium mb-2">个人设置</h3>
<div class="space-y-3">
<div>
<label class="block text-xs text-gray-400 mb-1">昵称</label>
<input type="text" id="nicknameInput" class="w-full bg-transparent border border-white/20 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" placeholder="输入昵称">
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">头像</label>
<div class="flex items-center gap-3">
<img id="avatarPreview" src="/images/p1.png" class="w-10 h-10 rounded-full object-cover">
<input type="file" id="avatarInput" accept="image/*" class="hidden" onchange="handleAvatarUpload(event)">
<button onclick="document.getElementById('avatarInput').click()" class="text-xs text-indigo-400 hover:text-indigo-300 transition-colors">更换头像</button>
</div>
</div>
<div>
<label class="block text-xs text-gray-400 mb-1">用户ID</label>
<div class="flex items-center gap-2">
<input type="text" id="userIdInput" class="w-full bg-transparent border border-white/20 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" readonly>
<button onclick="copyUserId()" class="text-xs text-indigo-400 hover:text-indigo-300 transition-colors">复制</button>
</div>
</div>
</div>
</div>
<div class="p-2">
<button onclick="saveSettings()" class="w-full px-4 py-2 text-sm text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors">保存设置</button>
</div>
</div>
</div>
<!-- 连接表单 -->
<div class="h-full w-full flex items-center justify-center bg-black/90">
<div class="text-center max-w-md px-8">
<div class="w-24 h-24 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-full flex items-center justify-center mx-auto mb-8 shadow-lg">
<i class="fas fa-video text-white text-4xl"></i>
</div>
<h1 class="text-3xl font-bold text-white mb-2">VideoCall</h1>
<p class="text-gray-400 mb-8">一对一视频通话</p>
<div class="space-y-4 mb-8">
<div class="glass rounded-xl p-4">
<label class="block text-sm font-medium text-gray-300 mb-2">连接ID</label>
<input type="text"
id="connectionIdInput"
placeholder="输入连接ID"
class="w-full bg-transparent border border-white/20 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
autocomplete="off">
</div>
<p class="text-xs text-gray-500">
连接ID是用于建立点对点通话的唯一标识由发起方生成并分享给接收方。
</p>
</div>
<div class="flex flex-col sm:flex-row gap-4 justify-center mb-6">
<button id="connectBtn" class="flex-1 px-6 py-3 bg-indigo-600 hover:bg-indigo-700 rounded-xl transition-colors flex items-center justify-center gap-2">
<i class="fas fa-phone"></i>
<span>加入通话</span>
</button>
<button id="createCallBtn" class="flex-1 px-6 py-3 glass hover:bg-white/10 rounded-xl transition-colors flex items-center justify-center gap-2">
<i class="fas fa-plus"></i>
<span>创建通话</span>
</button>
</div>
<!-- 浏览全部ID按钮 -->
<button id="browseIdsBtn" class="w-full px-6 py-3 glass hover:bg-white/10 rounded-xl transition-colors flex items-center justify-center gap-2 mb-4">
<i class="fas fa-list"></i>
<span>浏览全部ID</span>
</button>
<!-- 连接ID列表 -->
<div id="connectionIdsList" class="glass rounded-xl p-4 mb-6 hidden">
<h3 class="text-sm font-medium text-gray-300 mb-2">可用的连接ID</h3>
<div id="idsContainer" class="max-h-40 overflow-y-auto space-y-2">
<!-- 连接ID将在这里动态生成 -->
</div>
</div>
<div id="onlineUsersList" class="glass rounded-xl p-4 mb-6 hidden">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-medium text-gray-300">全部WebSocket用户</h3>
<span id="onlineUsersSummary" class="text-xs text-gray-500">0 个用户在线</span>
</div>
<div id="usersContainer" class="max-h-56 overflow-y-auto space-y-3">
<!-- 在线用户将在这里动态生成 -->
</div>
</div>
<!-- WebSocket连接状态指示 -->
<div id="wsStatus" class="mt-4 flex items-center justify-center gap-2 text-xs text-gray-500">
<span id="wsStatusDot" class="w-2 h-2 bg-gray-500 rounded-full"></span>
<span id="wsStatusText">未连接</span>
</div>
</div>
</div>
</div>
<!--
============================================================
call视图视频通话界面创建/加入房间后显示)
============================================================
-->
<div id="callView" class="hidden h-full w-full flex flex-col">
<!--
============================================================
数据模型定义 (Data Models)
============================================================
1. CallSession 通话会话
interface CallSession {
id: string; // 通话唯一ID [PRIMARY_KEY]
type: 'video' | 'audio'; // 通话类型
status: 'connecting' | 'ongoing' | 'ended' | 'failed';
startTime: string; // ISO 8601 格式
duration: number; // 已进行秒数
isEncrypted: boolean; // 是否端到端加密
localUser: LocalUser; // 本地用户信息
remoteUser: RemoteUser; // 远端用户信息
}
2. LocalUser 本地用户
interface LocalUser {
id: string; // 用户ID
name: string; // 显示名称
avatar: string; // 头像URL
isHost: boolean; // 是否主持人
mediaState: MediaState; // 媒体状态
}
3. RemoteUser 远端用户
interface RemoteUser {
id: string; // 用户ID
name: string; // 显示名称
avatar: string; // 头像URL
status: 'online' | 'offline' | 'connecting';
mediaState: MediaState; // 媒体状态
networkQuality: 'excellent' | 'good' | 'fair' | 'poor'; // 网络质量
}
4. MediaState 媒体状态
interface MediaState {
audio: boolean; // 音频是否开启
video: boolean; // 视频是否开启
screenShare: boolean; // 是否屏幕共享
isSpeaking: boolean; // 是否正在说话(VAD检测)
}
5. ChatMessage 聊天消息
interface ChatMessage {
id: string; // 消息ID
senderId: string; // 发送者ID
senderName: string; // 发送者名称
senderAvatar: string; // 发送者头像
content: string; // 消息内容
type: 'text' | 'file' | 'system';
timestamp: string; // ISO 8601 格式
isSelf: boolean; // 是否自己发送
}
============================================================
API 接口定义 (API Endpoints)
============================================================
[GET] /api/call/:callId // 获取通话信息
[POST] /api/call/:callId/join // 加入通话
[POST] /api/call/:callId/leave // 离开通话
[POST] /api/call/:callId/media // 更新媒体状态 {audio?: boolean, video?: boolean}
[GET] /api/call/:callId/messages?limit=50&before=timestamp // 获取历史消息
[POST] /api/call/:callId/message // 发送消息 {content: string, type: 'text'}
WebSocket Events:
- connect: 连接建立
- disconnect: 连接断开
- user-joined: {userId, timestamp}
- user-left: {userId, timestamp}
- media-state-changed: {userId, audio, video, screenShare, isSpeaking}
- message-received: {message: ChatMessage}
- network-quality: {userId, quality: 'excellent' | 'good' | 'fair' | 'poor'}
- call-ended: {reason: 'user_hangup' | 'network_error' | 'timeout'}
-->
<!--
============================================================
区域: 顶部栏 (Header)
数据源: CallSession
更新频率: 实时 (WebSocket + 本地计时器)
============================================================
-->
<header class="glass-strong h-16 flex items-center justify-between px-6 z-50 border-b border-white/10">
<div class="flex items-center gap-3">
<div
class="w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg">
<i class="fas fa-video text-white text-lg"></i>
</div>
<div>
<!-- [DATA_FIELD: callSession.remoteUser.name] [TYPE: string] [REQUIRED] -->
<h1 class="font-bold text-lg tracking-tight" data-field="remoteUser.name" id="headerTitle">
与 Sarah 的通话
</h1>
<div class="flex items-center gap-3 text-xs text-gray-400">
<span id="remoteNetworkIndicator" class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
<span id="remoteNetworkQuality" class="flex items-center gap-1">
<i class="fas fa-signal"></i>
<span>优秀</span>
</span>
<!-- [DATA_FIELD: callSession.duration] [TYPE: string] [FORMAT: MM:SS] [UPDATE: 每秒] -->
<span data-field="callSession.duration" id="callDuration">00:00</span>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<!-- [CONDITIONAL_RENDER: callSession.isEncrypted === true] -->
<div class="hidden md:flex items-center gap-2 px-4 py-2 glass rounded-full text-sm" id="encryptionBadge">
<i class="fas fa-shield-alt text-green-400"></i>
<span class="text-gray-300">端到端加密</span>
</div>
</div>
</header>
<main class="flex-1 flex overflow-hidden relative">
<!--
============================================================
区域: 视频区域 (Video Area)
数据源: CallSession.remoteUser (对方) + CallSession.localUser (自己)
更新频率: 实时 (WebRTC MediaStream + WebSocket 状态)
============================================================
-->
<div class="flex-1 relative bg-black/40 overflow-hidden" id="videoArea">
<!--
子区域: 多Participant视频网格Host端显示
动态生成每个participant一个视频格子
-->
<div id="participantGrid" class="hidden absolute inset-0 grid gap-3 p-3 auto-rows-fr" style="grid-template-columns: 1fr;">
<!-- 动态生成的 participant 视频格子将插入这里 -->
</div>
<!--
子区域: 远端视频 (Remote Video) - 单路Participant端显示Host画面
数据源: RemoteUser
-->
<div class="absolute inset-0 video-fade-in">
<!-- [DATA_FIELD: remoteUser.videoStream] [TYPE: MediaStream | null] [BIND: srcObject] -->
<!-- [FALLBACK: remoteUser.avatar] [TYPE: string] [URL] -->
<video id="remoteVideo" alt="对方视频" class="w-full h-full object-contain" autoplay
data-field="remoteUser.videoStream">
</video>
<!-- 远端未连接时的占位背景 -->
<div id="remoteVideoPlaceholder"
class="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-indigo-900 to-purple-900 z-10">
<div class="text-center">
<div
class="w-32 h-32 rounded-full bg-indigo-700/50 flex items-center justify-center mx-auto mb-4">
<i class="fas fa-user text-4xl text-white/70"></i>
</div>
<p class="text-white text-lg font-medium">等待对方连接...</p>
<p class="text-sm text-gray-400 mt-2">请确保对方已加入通话</p>
</div>
</div>
<!-- 网络状态提示 -->
<!-- [CONDITIONAL_RENDER: remoteUser.networkQuality !== 'excellent'] -->
<div id="networkStatus"
class="absolute top-6 right-6 glass px-3 py-1.5 rounded-full flex items-center gap-2 text-xs hidden"
data-field="remoteUser.networkQuality">
<i class="fas fa-exclamation-triangle text-yellow-500"></i>
<!-- [DATA_FIELD: remoteUser.networkQuality] [TYPE: string] [TRANSFORM: quality => text] -->
<span class="text-gray-300" id="networkStatusText">网络不稳定</span>
</div>
<!-- 连接中/重连提示 -->
<!-- [CONDITIONAL_RENDER: callSession.status === 'connecting'] -->
<div id="connectingOverlay" class="absolute inset-0 bg-black/60 flex items-center justify-center hidden"
data-field="callSession.status">
<div class="text-center">
<div
class="w-12 h-12 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-3">
</div>
<p class="text-white font-medium">正在连接...</p>
<p class="text-sm text-gray-400 mt-1" id="connectingText">等待对方接受邀请</p>
</div>
</div>
</div>
<!--
子区域: 本地视频 (Local Video - Picture in Picture)
数据源: LocalUser
-->
<div
class="absolute bottom-6 right-6 w-64 h-48 rounded-2xl overflow-hidden shadow-2xl border-2 border-white/20 video-fade-in z-10">
<!-- [DATA_FIELD: localUser.videoStream] [TYPE: MediaStream | null] [BIND: srcObject] -->
<!-- [FALLBACK: localUser.avatar] [TYPE: string] [URL] -->
<video id="localVideo"
src="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=300&fit=crop" alt="本地视频"
class="w-full h-full object-cover" autoplay muted data-field="localUser.videoStream">
</video>
<!-- [CONDITIONAL_RENDER: localUser.mediaState.video === false] -->
<div id="localVideoPlaceholder"
class="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-indigo-900 to-purple-900 hidden"
data-field="localUser.videoOff">
<span class="text-4xl font-bold" id="localInitials"></span>
</div>
<div class="absolute bottom-3 left-3 glass px-2 py-1 rounded text-xs flex items-center gap-2">
<span></span>
<!-- [CONDITIONAL_RENDER: localUser.mediaState.isSpeaking === true] -->
<div id="localAudioWave" class="audio-wave w-4 hidden" data-field="localUser.isSpeaking">
<span></span><span></span><span></span>
</div>
</div>
<!-- 本地视频悬停控制 -->
<div
class="absolute inset-0 bg-black/40 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<button onclick="toggleLocalVideo()"
class="w-8 h-8 rounded-full bg-white/20 hover:bg-white/30 flex items-center justify-center transition-colors">
<i class="fas fa-video text-xs" id="localVideoIcon"></i>
</button>
</div>
</div>
</div>
<!--
============================================================
区域: 侧边栏 (Sidebar)
数据源: ChatMessage[] + User[]
更新频率: 实时 (WebSocket)
============================================================
-->
<aside class="w-80 glass-strong border-l border-white/10 flex flex-col hidden" id="sidebar">
<!--
子区域: 用户列表 (User List)
数据源: [localUser, remoteUser]
-->
<div class="p-4 border-b border-white/10">
<h3 class="text-sm font-medium text-gray-400 mb-3" id="userCountDisplay">
通话成员 (1)
</h3>
<div class="space-y-2" id="userList">
<!-- [LOOP_START: users as user] -->
<!-- 远端用户项 -->
<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="" class="w-10 h-10 rounded-full object-cover" data-field="remoteUser.avatar">
<!-- [CONDITIONAL_RENDER: remoteUser.status === 'online'] -->
<div
class="absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-slate-900">
</div>
</div>
<div class="flex-1">
<!-- [DATA_FIELD: remoteUser.name] -->
<div class="text-sm font-medium" data-field="remoteUser.name">Sarah Chen</div>
<!-- [DATA_FIELD: remoteUser.mediaState] [TRANSFORM: state => statusText] -->
<div class="text-xs text-gray-500" data-field="remoteUser.mediaStatus">在线</div>
</div>
<div class="flex items-center gap-2">
<!-- [CONDITIONAL_RENDER: remoteUser.mediaState.audio === false] -->
<i class="fas fa-microphone-slash text-gray-500 text-xs hidden"
data-field="remoteUser.muteIcon"></i>
<!-- [CONDITIONAL_RENDER: remoteUser.mediaState.isSpeaking === true] -->
<div class="audio-wave w-6 hidden" data-field="remoteUser.speakingIndicator">
<span></span><span></span><span></span><span></span><span></span>
</div>
</div>
</div>
<!-- 本地用户项 -->
<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="" class="w-10 h-10 rounded-full object-cover" data-field="localUser.avatar">
<div class="flex-1">
<div class="text-sm font-medium">
<!-- [DATA_FIELD: localUser.name] -->
<!-- [CONDITIONAL_RENDER: localUser.isHost === true] -->
<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">主持人</span>
</div>
<!-- [DATA_FIELD: localUser.mediaState] [TRANSFORM: state => statusText] -->
<div class="text-xs text-gray-500" id="localMediaStatus" data-field="localUser.mediaStatus">
静音中</div>
</div>
<!-- [CONDITIONAL_RENDER: localUser.mediaState.audio === false] -->
<i class="fas fa-microphone-slash text-gray-500 text-xs" data-field="localUser.muteIcon"></i>
</div>
<!-- [LOOP_END: users] -->
</div>
</div>
<!--
子区域: 聊天消息列表 (Chat Messages)
数据源: ChatMessage[]
排序: 按 timestamp 升序
-->
<div class="flex-1 overflow-y-auto p-4 space-y-4 custom-scrollbar" id="chatContent">
<!-- [STATIC] 通话开始时间 -->
<div class="text-center text-xs text-gray-500 my-4">
通话开始 <!-- [DATA_FIELD: callSession.startTime] [FORMAT: HH:MM] -->14:30
</div>
<!-- [LOOP_START: messages as message] -->
<!-- 消息模板 (对方) -->
<!-- [CONDITIONAL_RENDER: message.isSelf === false] -->
<div class="chat-bubble" data-message-id="${message.id}">
<div class="flex gap-3">
<!-- [DATA_FIELD: message.senderAvatar] -->
<img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100&h=100&fit=crop"
class="w-8 h-8 rounded-full object-cover" data-field="message.senderAvatar">
<div class="flex-1">
<div class="flex items-baseline gap-2 mb-1">
<!-- [DATA_FIELD: message.senderName] -->
<span class="text-sm font-medium text-indigo-400" data-field="message.senderName">Sarah
Chen</span>
<!-- [DATA_FIELD: message.timestamp] [FORMAT: HH:MM] -->
<span class="text-xs text-gray-500" data-field="message.time">14:32</span>
</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>
</div>
<!-- 消息模板 (自己) -->
<!-- [CONDITIONAL_RENDER: message.isSelf === true] -->
<div class="chat-bubble">
<div class="flex gap-3 flex-row-reverse">
<!-- [DATA_FIELD: message.senderAvatar] -->
<img src="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop"
class="w-8 h-8 rounded-full object-cover" data-field="message.senderAvatar">
<div class="flex-1 flex flex-col items-end">
<div class="flex items-baseline gap-2 mb-1 flex-row-reverse">
<span class="text-sm font-medium text-green-400"></span>
<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>
</div>
<!-- [LOOP_END: messages] -->
</div>
<!--
子区域: 消息输入 (Message Input)
API: [POST] /api/call/:callId/message
-->
<div class="p-4 border-t border-white/10">
<div class="glass rounded-2xl flex items-center gap-2 p-2">
<button
class="w-8 h-8 rounded-full hover:bg-white/10 flex items-center justify-center text-gray-400 transition-colors"
onclick="openImagePicker()">
<i class="fas fa-plus"></i>
</button>
<!-- 隐藏的文件输入元素 -->
<input type="file" id="imageInput" accept="image/*" class="hidden"
onchange="handleImageUpload(event)">
<!-- [INPUT_FIELD] [BIND: inputValue] [EVENT: onEnter => sendMessage()] -->
<input type="text" id="chatInput" placeholder="输入消息..."
class="flex-1 bg-transparent border-none outline-none text-sm text-white placeholder-gray-500 px-2"
data-field="chatInput" onkeypress="handleChatSubmit(event)">
<!-- [BUTTON] [EVENT: onclick => sendMessage()] -->
<button onclick="sendMessage()"
class="w-8 h-8 rounded-full bg-indigo-600 hover:bg-indigo-700 flex items-center justify-center transition-colors">
<i class="fas fa-paper-plane text-xs"></i>
</button>
</div>
</div>
</aside>
</main>
<!--
============================================================
区域: 底部控制栏 (Control Bar)
数据源: LocalUser.mediaState
API: [POST] /api/call/:callId/media
WebSocket: emit 'media-state-changed'
============================================================
-->
<footer class="glass-strong h-20 border-t border-white/10 flex items-center justify-center px-6 gap-4 z-50">
<!-- 左侧连接信息 -->
<div class="absolute left-6 hidden md:flex items-center gap-3">
<div class="text-left">
<div class="text-sm font-medium">一对一通话</div>
<!-- [DATA_FIELD: remoteUser.networkQuality] [TRANSFORM: quality => displayText] -->
<div class="text-xs text-gray-400" id="connectionQuality" data-field="connectionQualityText">
连接质量: 优秀
</div>
</div>
</div>
<!-- 中间控制按钮组 -->
<div class="flex items-center gap-3">
<!-- 麦克风控制 -->
<!-- [DATA_FIELD: localUser.mediaState.audio] [TYPE: boolean] -->
<button
class="control-btn w-12 h-12 rounded-full glass flex items-center justify-center text-white hover:bg-white/10 relative group"
onclick="toggleMute(this)" id="micBtn" data-field="localUser.audio" data-active="false">
<i class="fas fa-microphone text-lg" data-icon="default"></i>
<i class="fas fa-microphone-slash text-lg hidden text-red-400" data-icon="active"></i>
<span
class="absolute -top-10 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
静音 (Space)
</span>
</button>
<!-- 摄像头控制 -->
<!-- [DATA_FIELD: localUser.mediaState.video] [TYPE: boolean] -->
<button
class="control-btn w-12 h-12 rounded-full glass flex items-center justify-center text-white hover:bg-white/10 relative group"
onclick="toggleVideo(this)" id="videoBtn" data-field="localUser.video" data-active="false">
<i class="fas fa-video text-lg" data-icon="default"></i>
<i class="fas fa-video-slash text-lg hidden text-red-400" data-icon="active"></i>
<span
class="absolute -top-10 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
关闭视频 (Ctrl+V)
</span>
</button>
<!-- 录屏控制 -->
<!-- [DATA_FIELD: localUser.mediaState.recording] [TYPE: boolean] -->
<button
class="control-btn w-12 h-12 rounded-full glass flex items-center justify-center text-white hover:bg-white/10 relative group"
onclick="toggleRecording(this)" id="recordBtn" data-field="localUser.recording" data-active="false">
<i class="fas fa-circle text-lg" data-icon="default"></i>
<i class="fas fa-stop text-lg hidden text-red-400" data-icon="active"></i>
<span
class="absolute -top-10 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
录制
</span>
</button>
<!-- 更多选项 -->
<div class="relative">
<button id="moreOptionsBtn"
class="control-btn w-12 h-12 rounded-full glass flex items-center justify-center text-white hover:bg-white/10 relative group">
<i class="fas fa-ellipsis-h text-lg"></i>
<span
class="absolute -top-10 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
更多选项
</span>
</button>
<!-- 更多选项下拉菜单 -->
<div id="moreOptionsMenu" class="hidden absolute bottom-full left-1/2 transform -translate-x-1/2 mb-3 glass rounded-xl shadow-lg w-52 z-50">
<!-- 分辨率选项 -->
<div class="p-3 border-b border-white/10">
<h4 class="text-xs font-medium text-gray-400 mb-2 flex items-center gap-2">
<i class="fas fa-desktop text-xs"></i>
视频分辨率
</h4>
<div class="space-y-1" id="resolutionOptions">
<button onclick="changeResolution(480, 270)" data-resolution="480"
class="resolution-option w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-white/10 transition-colors">
<span>流畅 480p</span>
<span class="text-xs text-gray-500">省流量</span>
</button>
<button onclick="changeResolution(1280, 720)" data-resolution="720"
class="resolution-option w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-white/10 transition-colors">
<span>高清 720p</span>
<span class="text-xs text-gray-500">推荐</span>
</button>
<button onclick="changeResolution(1920, 1080)" data-resolution="1080"
class="resolution-option active w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-white/10 transition-colors">
<span>超清 1080p</span>
<span class="text-xs text-gray-500"></span>
</button>
<button onclick="changeResolution(2560, 1440)" data-resolution="1440"
class="resolution-option w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-white/10 transition-colors">
<span>2K 1440p</span>
<span class="text-xs text-gray-500">最高画质</span>
</button>
</div>
</div>
<!-- 当前分辨率指示 -->
<div class="px-3 py-2 flex items-center gap-2">
<i class="fas fa-info-circle text-xs text-gray-500"></i>
<span id="currentResolutionText" class="text-xs text-gray-500">当前: 1080p</span>
</div>
</div>
</div>
<!-- 结束通话 -->
<!-- [EVENT: onclick => endCall()] [API: POST /api/call/:callId/leave] -->
<button
class="control-btn w-14 h-14 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center text-white end-call-pulse ml-4 relative group"
onclick="endCall()">
<i class="fas fa-phone-slash text-xl"></i>
<span
class="absolute -top-10 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
结束通话
</span>
</button>
</div>
<!-- 右侧聊天按钮 -->
<div class="absolute right-6 flex items-center gap-3">
<button
class="control-btn w-10 h-10 rounded-full glass flex items-center justify-center text-gray-300 hover:text-white hover:bg-white/10 transition-colors relative"
onclick="toggleSidebar()">
<i class="fas fa-comment-alt"></i>
<!-- 未读消息计数角标 -->
<span id="unreadBadge"
class="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center text-xs font-bold text-white hidden">0</span>
</button>
</div>
</footer>
</div><!-- /callView -->
<!-- 通知组件 -->
<div id="notification"
class="fixed top-20 left-1/2 transform -translate-x-1/2 glass px-6 py-3 rounded-full flex items-center gap-3 opacity-0 pointer-events-none transition-all duration-300 z-50 translate-y-[-20px]">
<i class="fas fa-info-circle text-indigo-400"></i>
<span class="text-sm" id="notificationText">通知内容</span>
</div>
<!-- 通话结束确认对话框 -->
<div id="endCallDialog" 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-red-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="fas fa-phone-slash text-red-500 text-2xl"></i>
</div>
<h3 class="text-xl font-bold mb-2">结束通话</h3>
<p class="text-gray-400 text-sm">确定要结束当前通话吗?</p>
</div>
<div class="flex gap-3">
<button id="cancelEndCall" class="flex-1 py-2 rounded-lg glass hover:bg-white/10 transition-colors">
取消
</button>
<button id="confirmEndCall"
class="flex-1 py-2 rounded-lg bg-red-500 hover:bg-red-600 transition-colors">
结束通话
</button>
</div>
</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-3 rounded-lg bg-white/5 px-3 py-2 text-left">
<div class="text-xs text-gray-500 mb-1">申请理由</div>
<div class="text-sm text-gray-200 break-words" id="callRequestReason">未填写</div>
</div>
<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="/call/connectview.js"></script>
<script type="module" src="/call/main.js"></script>
</body>
</html>

129
client/public/call/main.js Normal file
View File

@@ -0,0 +1,129 @@
import store from './store.js';
import UIRenderer from './renderers/renderer.js';
import { showNotification, randomMeetingId } from '../shared/utils.js';
import chatMessage from './chat/chatmessage.js';
import { createCallViewController } from './controllers/call-view-controller.js';
import {
bindConnectViewEvents,
initWebSocket,
loadUserSettings
} from './connectview.js';
import { createInviteController } from './controllers/invite-controller.js';
import { createLogger } from '../shared/logger.js';
const logger = createLogger('main');
let connectionId = '';
let currentView = 'connect';
function updateConnectionId(nextConnectionId) {
connectionId = nextConnectionId || '';
if (connectionId) {
localStorage.setItem('connectionId', connectionId);
}
}
async function switchToCallView(targetConnectionId) {
const connectView = document.getElementById('connectView');
const callView = document.getElementById('callView');
if (connectView) {
connectView.classList.add('hidden');
}
if (callView) {
callView.classList.remove('hidden');
}
currentView = 'call';
try {
const renderer = new UIRenderer(store);
await store.joinCall(targetConnectionId);
await store.setUp(targetConnectionId);
renderer.renderHeaderTitle();
callViewController.bindDomEvents();
logger.debug('Video call app initialized successfully');
return true;
} catch (error) {
logger.error('Error initializing app:', error);
showNotification('初始化失败,请刷新页面重试', 'error');
return false;
}
}
const inviteController = createInviteController({
store,
notify: showNotification,
onAcceptConnection: switchToCallView,
getCurrentView: () => currentView,
getConnectionId: () => connectionId,
setConnectionId: updateConnectionId
});
const callViewController = createCallViewController({
store,
chatMessage,
notify: showNotification
});
async function handleJoinCall(targetConnectionId) {
showNotification(`正在加入通话 (${targetConnectionId})`);
updateConnectionId(targetConnectionId);
await switchToCallView(targetConnectionId);
}
async function handleCreateCall() {
showNotification('正在创建通话...');
const nextConnectionId = randomMeetingId();
updateConnectionId(nextConnectionId);
await switchToCallView(nextConnectionId);
}
window.addEventListener('DOMContentLoaded', async () => {
try {
const connectView = document.getElementById('connectView');
const callView = document.getElementById('callView');
if (connectView) {
connectView.classList.remove('hidden');
}
if (callView) {
callView.classList.add('hidden');
}
currentView = 'connect';
loadUserSettings();
await initWebSocket();
inviteController.bindSignalHandlers();
inviteController.bindDialogEvents();
bindConnectViewEvents(handleJoinCall, handleCreateCall);
const savedConnectionId = localStorage.getItem('connectionId');
if (savedConnectionId) {
updateConnectionId(savedConnectionId);
const connectionIdInput = document.getElementById('connectionIdInput');
if (connectionIdInput) {
connectionIdInput.value = savedConnectionId;
}
}
const invitePayload = inviteController.getInvitePayloadFromUrl();
if (invitePayload) {
inviteController.showCallRequestDialog(invitePayload);
}
logger.debug('SPA initialized, showing connect view');
} catch (error) {
logger.error('Error initializing SPA:', error);
showNotification('初始化失败,请刷新页面重试', 'error');
}
});
export { store };

View File

@@ -0,0 +1,80 @@
export const AUDIO_CONFIG = {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
};
export const VAD_CONFIG = {
threshold: 15,
debounceTime: 500,
fftSize: 256
};
export const MEDIA_CONSTRAINTS = {
video: {
width: { ideal: 1920, max: 1920 },
height: { ideal: 1080, max: 1080 },
frameRate: { ideal: 30, max: 30 }
},
audio: AUDIO_CONFIG
};
export const VIDEO_ONLY_CONSTRAINT = {
video: {
width: { ideal: 1920, max: 1920 },
height: { ideal: 1080, max: 1080 },
frameRate: { ideal: 30, max: 30 }
},
audio: false
};
const TARGET_RESOLUTION_BITRATE_MAP = {
270: 1000000,
480: 1500000,
720: 2500000,
1080: 4000000,
1440: 6000000
};
export function buildVideoConstraints(savedResolution) {
if (!savedResolution) {
return MEDIA_CONSTRAINTS.video;
}
return {
width: { ideal: savedResolution.width, max: savedResolution.width },
height: { ideal: savedResolution.height, max: savedResolution.height },
frameRate: { ideal: 30, max: 30 }
};
}
export function getResolutionLabel(height) {
if (height >= 1440) {
return '2K 1440p';
}
if (height >= 1080) {
return '1080p 超清';
}
if (height >= 720) {
return '720p 高清';
}
return '480p 流畅';
}
export function getTargetResolutionBitrate(height) {
return TARGET_RESOLUTION_BITRATE_MAP[height] || 2500000;
}
export function getAdaptiveVideoBitrate(height) {
const supportedHeights = Object.keys(TARGET_RESOLUTION_BITRATE_MAP).map(Number).sort((a, b) => a - b);
let maxBitrate = TARGET_RESOLUTION_BITRATE_MAP[1080];
for (const supportedHeight of supportedHeights) {
if (height <= supportedHeight) {
return TARGET_RESOLUTION_BITRATE_MAP[supportedHeight];
}
maxBitrate = TARGET_RESOLUTION_BITRATE_MAP[supportedHeight];
}
return maxBitrate;
}

View File

@@ -0,0 +1,62 @@
export function createAudioAnalyser(stream, fftSize) {
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
const audioContext = new AudioContextCtor();
const source = audioContext.createMediaStreamSource(stream);
const analyser = audioContext.createAnalyser();
analyser.fftSize = fftSize;
source.connect(analyser);
return {
audioContext,
analyser,
dataArray: new Uint8Array(analyser.frequencyBinCount)
};
}
export function getAudioLevel(analyser, dataArray) {
analyser.getByteTimeDomainData(dataArray);
let sum = 0;
for (let i = 0; i < dataArray.length; i++) {
const amplitude = dataArray[i] - 128;
sum += amplitude * amplitude;
}
const rms = Math.sqrt(sum / dataArray.length);
return rms / 128;
}
function formatPacketLossRate(packetsLost, packetsReceived) {
if (packetsReceived <= 0) {
return '0%';
}
return `${((packetsLost / (packetsLost + packetsReceived)) * 100).toFixed(2)}%`;
}
export function buildStatsLogPayload(networkQuality, statsSummary) {
return {
networkQuality,
video: {
'Packets Lost': statsSummary.video.packetsLost,
'Packets Received': statsSummary.video.packetsReceived,
'Packet Loss Rate': formatPacketLossRate(
statsSummary.video.packetsLost,
statsSummary.video.packetsReceived
),
'Jitter': `${(statsSummary.video.jitter * 1000).toFixed(2)}ms`,
'Round Trip Time': `${(statsSummary.video.roundTripTime * 1000).toFixed(2)}ms`,
'FPS': statsSummary.video.fps.toFixed(1),
'Bitrate': `${statsSummary.video.bitrate}kbps`
},
audio: {
'Packets Lost': statsSummary.audio.packetsLost,
'Packets Received': statsSummary.audio.packetsReceived,
'Packet Loss Rate': formatPacketLossRate(
statsSummary.audio.packetsLost,
statsSummary.audio.packetsReceived
),
'Jitter': `${(statsSummary.audio.jitter * 1000).toFixed(2)}ms`
}
};
}

View File

@@ -0,0 +1,336 @@
const DEFAULT_WIDTH = 1280;
const DEFAULT_HEIGHT = 720;
const DEFAULT_FPS = 30;
const MIME_TYPE_CANDIDATES = [
{ mimeType: 'video/mp4;codecs=avc1.42E01E,mp4a.40.2', extension: 'mp4' },
{ mimeType: 'video/mp4', extension: 'mp4' },
{ mimeType: 'video/webm;codecs=vp9,opus', extension: 'webm' },
{ mimeType: 'video/webm;codecs=vp8,opus', extension: 'webm' },
{ mimeType: 'video/webm', extension: 'webm' }
];
function getSupportedFormat(mediaRecorderCtor) {
if (!mediaRecorderCtor || typeof mediaRecorderCtor.isTypeSupported !== 'function') {
return {
mimeType: '',
extension: 'webm'
};
}
return MIME_TYPE_CANDIDATES.find(format => mediaRecorderCtor.isTypeSupported(format.mimeType)) || {
mimeType: '',
extension: 'webm'
};
}
function isElementVisible(element) {
if (!element || element.classList.contains('hidden')) {
return false;
}
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function drawVideoCover(context, video, x, y, width, height) {
const videoWidth = video.videoWidth || width;
const videoHeight = video.videoHeight || height;
const sourceRatio = videoWidth / videoHeight;
const targetRatio = width / height;
let sourceX = 0;
let sourceY = 0;
let sourceWidth = videoWidth;
let sourceHeight = videoHeight;
if (sourceRatio > targetRatio) {
sourceWidth = videoHeight * targetRatio;
sourceX = (videoWidth - sourceWidth) / 2;
} else {
sourceHeight = videoWidth / targetRatio;
sourceY = (videoHeight - sourceHeight) / 2;
}
context.drawImage(video, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
}
function drawEmptyFrame(context, canvas) {
context.fillStyle = '#111827';
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = '#9ca3af';
context.font = '24px sans-serif';
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText('等待会议画面...', canvas.width / 2, canvas.height / 2);
}
function drawGrid(context, videos, canvas) {
const columns = Math.ceil(Math.sqrt(videos.length));
const rows = Math.ceil(videos.length / columns);
const gap = 8;
const tileWidth = (canvas.width - gap * (columns - 1)) / columns;
const tileHeight = (canvas.height - gap * (rows - 1)) / rows;
videos.forEach((video, index) => {
const column = index % columns;
const row = Math.floor(index / columns);
const x = column * (tileWidth + gap);
const y = row * (tileHeight + gap);
drawVideoCover(context, video, x, y, tileWidth, tileHeight);
});
}
function drawLocalPreview(context, localVideo, canvas) {
const previewWidth = Math.floor(canvas.width * 0.22);
const previewHeight = Math.floor(previewWidth * 9 / 16);
const margin = 24;
const x = canvas.width - previewWidth - margin;
const y = canvas.height - previewHeight - margin;
context.fillStyle = 'rgba(0, 0, 0, 0.4)';
context.fillRect(x - 4, y - 4, previewWidth + 8, previewHeight + 8);
drawVideoCover(context, localVideo, x, y, previewWidth, previewHeight);
}
function collectStreams({ localStream, remoteStream, remoteStreams } = {}) {
return [
localStream,
remoteStream,
...Object.values(remoteStreams || {})
].filter(Boolean);
}
function collectLiveAudioTracks(streams) {
return streams.flatMap(stream => stream.getAudioTracks())
.filter(track => track.readyState !== 'ended');
}
export class MeetingRecorder {
constructor({
documentRef = document,
windowRef = window,
width = DEFAULT_WIDTH,
height = DEFAULT_HEIGHT,
fps = DEFAULT_FPS
} = {}) {
this.document = documentRef;
this.window = windowRef;
this.width = width;
this.height = height;
this.fps = fps;
this.mediaRecorder = null;
this.chunks = [];
this.animationFrameId = null;
this.audioContext = null;
this.audioSources = [];
this.recordingStream = null;
this.connectionId = '';
}
isSupported() {
return Boolean(
this.window.MediaRecorder &&
this.document.createElement('canvas').captureStream
);
}
isRecording() {
return Boolean(this.mediaRecorder && this.mediaRecorder.state !== 'inactive');
}
async start({ localStream, remoteStream, remoteStreams, connectionId } = {}) {
if (this.isRecording()) {
throw new Error('会议正在录制中');
}
if (!this.isSupported()) {
throw new Error('当前浏览器不支持会议录制');
}
const canvas = this.document.createElement('canvas');
canvas.width = this.width;
canvas.height = this.height;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('无法创建录制画布');
}
this.connectionId = connectionId || '';
this.chunks = [];
this.canvas = canvas;
this.context = context;
const canvasStream = canvas.captureStream(this.fps);
const streams = collectStreams({ localStream, remoteStream, remoteStreams });
const audioTrack = this.createMixedAudioTrack(streams);
const tracks = [
...canvasStream.getVideoTracks(),
...(audioTrack ? [audioTrack] : [])
];
this.recordingStream = new this.window.MediaStream(tracks);
try {
this.startDrawing();
this.startMediaRecorder(this.recordingStream);
}
catch (error) {
this.cleanup();
throw error;
}
}
stop() {
if (!this.isRecording()) {
return Promise.resolve(null);
}
return new Promise((resolve, reject) => {
this.pendingStop = { resolve, reject };
this.mediaRecorder.stop();
});
}
createMixedAudioTrack(streams) {
const audioTracks = collectLiveAudioTracks(streams);
if (audioTracks.length === 0) {
return null;
}
const AudioContextCtor = this.window.AudioContext || this.window.webkitAudioContext;
if (!AudioContextCtor) {
return audioTracks[0].clone ? audioTracks[0].clone() : audioTracks[0];
}
this.audioContext = new AudioContextCtor();
const destination = this.audioContext.createMediaStreamDestination();
audioTracks.forEach(track => {
const sourceStream = new this.window.MediaStream([track]);
const source = this.audioContext.createMediaStreamSource(sourceStream);
source.connect(destination);
this.audioSources.push(source);
});
return destination.stream.getAudioTracks()[0] || null;
}
startMediaRecorder(stream) {
const MediaRecorderCtor = this.window.MediaRecorder;
const format = getSupportedFormat(MediaRecorderCtor);
const options = format.mimeType ? { mimeType: format.mimeType } : {};
this.fileExtension = format.extension;
this.mediaRecorder = new MediaRecorderCtor(stream, options);
this.mediaRecorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
this.chunks.push(event.data);
}
};
this.mediaRecorder.onerror = (event) => {
if (this.pendingStop) {
this.pendingStop.reject(event.error || new Error('录制失败'));
this.pendingStop = null;
}
this.cleanup();
};
this.mediaRecorder.onstop = () => {
const blob = new Blob(this.chunks, { type: this.mediaRecorder.mimeType || 'video/webm' });
const filename = this.buildFilename();
const mimeType = blob.type || this.mediaRecorder.mimeType || 'video/webm';
this.cleanup();
if (this.pendingStop) {
this.pendingStop.resolve({ blob, filename, mimeType });
this.pendingStop = null;
}
};
this.mediaRecorder.start(1000);
}
startDrawing() {
const draw = () => {
this.drawFrame();
this.animationFrameId = this.window.requestAnimationFrame(draw);
};
draw();
}
drawFrame() {
const context = this.context;
const canvas = this.canvas;
const videos = this.getRecordableVideos();
const localVideo = videos.find(video => video.id === 'localVideo');
const remoteVideos = videos.filter(video => video !== localVideo);
context.fillStyle = '#020617';
context.fillRect(0, 0, canvas.width, canvas.height);
if (remoteVideos.length > 0) {
drawGrid(context, remoteVideos, canvas);
if (localVideo) {
drawLocalPreview(context, localVideo, canvas);
}
return;
}
if (localVideo) {
drawVideoCover(context, localVideo, 0, 0, canvas.width, canvas.height);
return;
}
drawEmptyFrame(context, canvas);
}
getRecordableVideos() {
return Array.from(this.document.querySelectorAll('#participantGrid video, #remoteVideo, #localVideo'))
.filter(video => video.srcObject && isElementVisible(video) && video.readyState >= 2);
}
download(blob, filename = this.buildFilename()) {
const url = this.window.URL.createObjectURL(blob);
const link = this.document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = 'none';
this.document.body.appendChild(link);
link.click();
link.remove();
this.window.setTimeout(() => {
this.window.URL.revokeObjectURL(url);
}, 1000);
return filename;
}
buildFilename() {
const datePart = new Date().toISOString().replace(/[:.]/g, '-');
const meetingPart = this.connectionId ? `-${this.connectionId}` : '';
return `meeting-recording${meetingPart}-${datePart}.${this.fileExtension || 'webm'}`;
}
cleanup() {
if (this.animationFrameId) {
this.window.cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
if (this.recordingStream) {
this.recordingStream.getTracks().forEach(track => track.stop());
this.recordingStream = null;
}
if (this.audioContext) {
this.audioContext.close();
this.audioContext = null;
}
this.audioSources = [];
this.mediaRecorder = null;
this.canvas = null;
this.context = null;
this.chunks = [];
}
}

View File

@@ -0,0 +1,191 @@
import { createParticipantTile, getParticipantTile } from '../participants/renderer-participant-grid.js';
import { createLogger } from '../../shared/logger.js';
const logger = createLogger('renderer-media');
export function getVideoResolution(track) {
if (track && track.getSettings) {
const settings = track.getSettings();
return {
width: settings.width || 640,
height: settings.height || 480
};
}
return { width: 640, height: 480 };
}
export function adjustVideoSize(videoElement) {
if (!videoElement) return;
const container = videoElement.parentElement;
if (!container) return;
videoElement.style.transform = 'translateZ(0)';
videoElement.style.willChange = 'transform';
container.style.display = 'flex';
container.style.alignItems = 'center';
container.style.justifyContent = 'center';
videoElement.style.imageRendering = 'auto';
videoElement.style.maxWidth = '100%';
videoElement.style.maxHeight = '100%';
videoElement.style.objectFit = 'contain';
}
export function renderParticipantStreamMedia({
grid,
stream,
connectionId,
displayName,
getGridTemplateColumns,
remoteVideo,
connectingOverlay,
remoteVideoPlaceholder
}) {
if (!grid) return;
grid.classList.remove('hidden');
let tile = getParticipantTile(grid, connectionId);
if (!tile) {
tile = createParticipantTile(connectionId, displayName);
grid.appendChild(tile);
logger.debug(`Created participant video tile for ${connectionId}`);
}
const video = tile.querySelector('video');
if (video && stream) {
if (video.srcObject === stream) {
logger.debug(`Same stream for participant ${connectionId}, ensuring playback`);
video.play().catch(error => logger.debug('Auto-play prevented:', error.message));
} else {
video.srcObject = stream;
video.play().catch(error => logger.debug('Auto-play prevented:', error.message));
logger.debug(`Set remote stream for participant tile ${connectionId}`);
}
}
const remoteVideoContainer = remoteVideo?.closest('.absolute.inset-0.video-fade-in');
if (remoteVideoContainer) {
remoteVideoContainer.classList.add('hidden');
}
const tileCount = grid.querySelectorAll('[data-participant-id]').length;
grid.style.gridTemplateColumns = getGridTemplateColumns(tileCount);
if (connectingOverlay) {
connectingOverlay.classList.add('hidden');
}
if (remoteVideoPlaceholder) {
remoteVideoPlaceholder.classList.add('hidden');
}
}
export function renderSingleRemoteStreamMedia({
remoteVideo,
stream,
disconnectedOverlay,
remoteVideoPlaceholder,
connectingOverlay
}) {
if (!remoteVideo || !stream) {
logger.error('Either remoteVideo element or stream is missing');
return;
}
logger.debug('Rendering remote stream:', stream, 'tracks:', stream.getTracks().map(track => `${track.kind}(${track.readyState})`));
if (remoteVideo.srcObject === stream) {
logger.debug('Same stream object, track added - ensuring playback');
remoteVideo.play().catch(error => logger.debug('Auto-play prevented:', error.message));
return;
}
remoteVideo.srcObject = stream;
remoteVideo.autoplay = true;
remoteVideo.playsinline = true;
remoteVideo.muted = false;
remoteVideo.play().catch(error => {
logger.debug('Auto-play prevented, will retry on interaction:', error.message);
});
if (disconnectedOverlay) {
disconnectedOverlay.classList.add('hidden');
}
const videoTracks = stream.getVideoTracks();
const audioTracks = stream.getAudioTracks();
logger.debug(`Stream has ${videoTracks.length} video tracks, ${audioTracks.length} audio tracks`);
if (videoTracks.length === 0) {
logger.debug('Audio-only stream, waiting for video track...');
return;
}
if (remoteVideoPlaceholder) {
remoteVideoPlaceholder.classList.add('hidden');
}
if (connectingOverlay) {
connectingOverlay.classList.add('hidden');
}
const activeVideoTrack = videoTracks.find(track => track.readyState === 'live');
if (!activeVideoTrack) return;
adjustVideoSize(remoteVideo, getVideoResolution(activeVideoTrack));
activeVideoTrack.addEventListener('resize', () => {
adjustVideoSize(remoteVideo, getVideoResolution(activeVideoTrack));
});
}
export function clearParticipantGrid(grid) {
if (!grid) return;
grid.querySelectorAll('[data-participant-id]').forEach(tile => {
const video = tile.querySelector('video');
if (video) {
video.srcObject = null;
}
tile.remove();
});
grid.classList.add('hidden');
}
export function removeParticipantTile({
grid,
connectionId,
getGridTemplateColumns,
remoteVideo,
remoteVideoPlaceholder,
remoteNetworkIndicator
}) {
if (!grid) return;
const tile = getParticipantTile(grid, connectionId);
if (tile) {
const video = tile.querySelector('video');
if (video) {
video.srcObject = null;
}
tile.remove();
logger.debug(`Removed participant video tile for ${connectionId}`);
}
const remainingTiles = grid.querySelectorAll('[data-participant-id]');
if (remainingTiles.length === 0) {
grid.classList.add('hidden');
const remoteVideoContainer = remoteVideo?.closest('.absolute.inset-0.video-fade-in');
if (remoteVideoContainer) {
remoteVideoContainer.classList.remove('hidden');
}
if (remoteVideoPlaceholder) {
remoteVideoPlaceholder.classList.remove('hidden');
}
} else {
grid.style.gridTemplateColumns = getGridTemplateColumns(remainingTiles.length);
}
if (remoteNetworkIndicator) {
remoteNetworkIndicator.className = 'w-2 h-2 bg-gray-500 rounded-full';
}
}

View File

@@ -0,0 +1,96 @@
function createStatsSummary() {
return {
video: {
packetsLost: 0,
packetsReceived: 0,
bytesReceived: 0,
jitter: 0,
roundTripTime: 0,
fps: 0,
bitrate: 0
},
audio: {
packetsLost: 0,
packetsReceived: 0,
bytesReceived: 0,
jitter: 0
},
network: {
totalPacketsLost: 0,
totalPacketsReceived: 0,
inboundRtpCount: 0,
jitter: 0,
roundTripTime: 0
}
};
}
export function summarizeInboundStats(stats) {
const summary = createStatsSummary();
stats.forEach((report) => {
if (report.type === 'inbound-rtp' && report.mediaType === 'video') {
summary.network.inboundRtpCount++;
if (report.packetsLost !== undefined && report.packetsReceived !== undefined) {
summary.network.totalPacketsLost += report.packetsLost;
summary.network.totalPacketsReceived += report.packetsReceived;
}
if (report.jitter !== undefined) {
summary.network.jitter = Math.max(summary.network.jitter, report.jitter);
}
if (report.roundTripTime !== undefined) {
summary.network.roundTripTime = Math.max(summary.network.roundTripTime, report.roundTripTime);
}
summary.video.packetsLost = report.packetsLost || 0;
summary.video.packetsReceived = report.packetsReceived || 0;
summary.video.bytesReceived = report.bytesReceived || 0;
summary.video.jitter = report.jitter || 0;
summary.video.roundTripTime = report.roundTripTime || 0;
summary.video.fps = report.framesPerSecond || 0;
if (report.bytesReceived && report.timestamp) {
const duration = report.timestamp / 1000;
summary.video.bitrate = duration > 0
? Math.round((report.bytesReceived * 8) / (duration * 1000))
: 0;
}
} else if (report.type === 'inbound-rtp' && report.mediaType === 'audio') {
summary.audio.packetsLost = report.packetsLost || 0;
summary.audio.packetsReceived = report.packetsReceived || 0;
summary.audio.bytesReceived = report.bytesReceived || 0;
summary.audio.jitter = report.jitter || 0;
}
});
return summary;
}
export function getNetworkQualityFromSummary(summary) {
const { totalPacketsLost, totalPacketsReceived, inboundRtpCount, jitter, roundTripTime } = summary.network;
if (inboundRtpCount === 0) {
return 'no_signal';
}
const packetLossRate = totalPacketsReceived > 0
? totalPacketsLost / (totalPacketsLost + totalPacketsReceived)
: 0;
const jitterMs = jitter * 1000;
const rttMs = roundTripTime * 1000;
if (packetLossRate > 0.05 || jitterMs > 100 || rttMs > 300) {
return 'poor';
}
if (packetLossRate > 0.02 || jitterMs > 50 || rttMs > 150) {
return 'fair';
}
if (packetLossRate > 0.01 || jitterMs > 30 || rttMs > 100) {
return 'good';
}
return 'excellent';
}

View File

@@ -0,0 +1,112 @@
/**
* 类型定义和数据模型
*/
/**
* @typedef {Object} CallSession
* @property {string} id - 通话唯一标识 (UUID)
* @property {'video'|'audio'} type - 通话类型
* @property {'connecting'|'ongoing'|'ended'|'failed'} status - 通话状态
* @property {string} startTime - ISO 8601 时间戳
* @property {number} duration - 已进行秒数
* @property {boolean} isEncrypted - 是否启用端到端加密
* @property {LocalUser} localUser - 本地用户信息
* @property {RemoteUser} remoteUser - 远端用户信息
*/
/**
* @typedef {Object} LocalUser
* @property {string} id - 用户ID
* @property {string} name - 显示名称
* @property {string} avatar - 头像URL
* @property {boolean} isHost - 是否主持人
* @property {MediaState} mediaState - 媒体状态
*/
/**
* @typedef {Object} RemoteUser
* @property {string} id - 用户ID
* @property {string} name - 显示名称
* @property {string} avatar - 头像URL
* @property {'online'|'offline'|'connecting'} status - 在线状态
* @property {MediaState} mediaState - 媒体状态
* @property {'excellent'|'good'|'fair'|'poor'} networkQuality - 网络质量
*/
/**
* @typedef {Object} MediaState
* @property {boolean} audio - 音频是否开启
* @property {boolean} video - 视频是否开启
* @property {boolean} screenShare - 是否屏幕共享
* @property {boolean} recording - 是否正在录屏
* @property {boolean} isSpeaking - 是否正在说话(VAD)
*/
/**
* @typedef {Object} ChatMessage
* @property {string} id - 消息唯一ID
* @property {string} senderId - 发送者ID
* @property {string} senderName - 发送者名称
* @property {string} senderAvatar - 发送者头像URL
* @property {string} content - 消息内容
* @property {'text'|'file'|'system'} type - 消息类型
* @property {string} timestamp - ISO 8601 时间戳
* @property {boolean} isSelf - 是否为自己发送
*/
// 模拟通话会话数据
const mockCallSession = {
id: "call-8842-2024-001",
type: "video",
status: "ongoing", // connecting | ongoing | ended | failed
startTime: "2024-01-15T14:30:00.000Z",
duration: 0, // 秒数,后端可不返回,前端本地计算
isEncrypted: true,
// 本地用户信息
localUser: {
id: "user-local-001",
name: "我",
avatar: "/images/p1.png",
isHost: true,
mediaState: {
audio: true,
video: true,
screenShare: false,
recording: false,
isSpeaking: false
}
},
// 远端用户信息
remoteUser: {
id: "user-remote-002",
name: "Unity",
avatar: "/images/p2.png",
status: "offline", // online | offline | connecting
networkQuality: "no_signal", // excellent | good | fair | poor | no_signal
mediaState: {
audio: true,
video: true,
screenShare: false,
recording: false,
isSpeaking: false
}
}
};
// 模拟聊天消息数据
const mockMessages = [
{
id: "msg-001",
senderId: "system",
senderName: "系统",
senderAvatar: "/images/screenshot.png",
content: "通话已建立连接",
type: "system",
timestamp: "2024-01-15T14:30:00.000Z",
isSelf: false
}
];
export { mockCallSession, mockMessages };

View File

@@ -0,0 +1,77 @@
export const DEFAULT_PARTICIPANT_NAME = '参与者';
export const DEFAULT_PARTICIPANT_AVATAR = '/images/p2.png';
const DEFAULT_MEDIA_STATE = Object.freeze({
audio: false,
video: true,
isSpeaking: false
});
function createParticipantRecord(current = {}, patch = {}) {
return {
id: '',
name: DEFAULT_PARTICIPANT_NAME,
avatar: DEFAULT_PARTICIPANT_AVATAR,
mediaState: { ...DEFAULT_MEDIA_STATE },
status: 'online',
...current,
...patch,
mediaState: {
...DEFAULT_MEDIA_STATE,
...(current.mediaState || {}),
...(patch.mediaState || {})
}
};
}
export function upsertParticipant(participants, participantId, patch = {}) {
if (!participantId) {
return null;
}
participants[participantId] = createParticipantRecord(participants[participantId], patch);
return participants[participantId];
}
export function removeParticipant(participants, participantId) {
if (!participantId || !participants[participantId]) {
return false;
}
delete participants[participantId];
return true;
}
export function omitParticipant(participants, excludedParticipantId) {
const filtered = {};
for (const [participantId, participant] of Object.entries(participants || {})) {
if (participantId !== excludedParticipantId) {
filtered[participantId] = participant;
}
}
return filtered;
}
export function buildParticipantsSyncData(localUser, participants) {
const memberList = {
host: {
id: localUser.id,
name: localUser.name,
avatar: localUser.avatar,
mediaState: { ...localUser.mediaState },
status: 'online',
role: 'host'
}
};
for (const [participantId, participant] of Object.entries(participants || {})) {
memberList[participantId] = {
...createParticipantRecord(participant),
role: 'participant'
};
}
return memberList;
}

View File

@@ -0,0 +1,64 @@
function createParticipantPlaceholder() {
const placeholder = document.createElement('div');
placeholder.className = 'participant-video-placeholder absolute inset-0 flex items-center justify-center bg-gradient-to-br from-indigo-900/80 to-purple-900/80 hidden';
placeholder.innerHTML = `
<div class="text-center">
<div class="w-20 h-20 rounded-full bg-indigo-700/50 flex items-center justify-center mx-auto mb-3">
<i class="fas fa-video-slash text-2xl text-white/70"></i>
</div>
<p class="text-white text-sm font-medium">\u6444\u50cf\u5934\u5df2\u5173\u95ed</p>
</div>
`;
return placeholder;
}
export function createParticipantTile(connectionId, displayName) {
const tile = document.createElement('div');
tile.className = 'relative bg-black/60 rounded-xl overflow-hidden flex items-center justify-center';
tile.dataset.participantId = connectionId;
const video = document.createElement('video');
video.className = 'w-full h-full object-contain';
video.autoplay = true;
video.playsinline = true;
video.muted = false;
video.id = `participantVideo_${connectionId}`;
tile.appendChild(video);
tile.appendChild(createParticipantPlaceholder());
const label = document.createElement('div');
label.className = 'absolute bottom-3 left-3 glass px-3 py-1 rounded-full text-xs flex items-center gap-2';
label.innerHTML = `<i class="fas fa-user text-purple-400"></i><span>${displayName || '\u53c2\u4e0e\u8005'}</span>`;
tile.appendChild(label);
const liveTag = document.createElement('div');
liveTag.className = 'absolute top-3 right-3 bg-green-500/80 px-2 py-0.5 rounded-full text-xs flex items-center gap-1';
liveTag.innerHTML = `<span class="w-1.5 h-1.5 bg-white rounded-full animate-pulse"></span><span>\u5728\u7ebf</span>`;
tile.appendChild(liveTag);
return tile;
}
export function getParticipantTile(grid, participantId) {
return grid?.querySelector(`[data-participant-id="${participantId}"]`) || null;
}
export function updateParticipantTilePlaceholder(grid, participantId, showPlaceholder) {
const tile = getParticipantTile(grid, participantId);
if (!tile) return;
const placeholder = tile.querySelector('.participant-video-placeholder');
if (placeholder) {
placeholder.classList.toggle('hidden', !showPlaceholder);
}
}
export function updateParticipantTileName(grid, participantId, name) {
const tile = getParticipantTile(grid, participantId);
if (!tile) return;
const label = tile.querySelector('.absolute.bottom-3 span');
if (label && name) {
label.textContent = name;
}
}

View File

@@ -0,0 +1,189 @@
const DEFAULT_NETWORK_QUALITY = {
label: '\u672a\u77e5',
statusIconClass: 'fas fa-question-circle text-gray-400',
statusTextClass: 'text-gray-400',
headerIconClass: 'fas fa-signal text-gray-400',
indicatorClass: 'w-2 h-2 bg-green-500 rounded-full animate-pulse',
connectionTextClass: 'text-gray-400'
};
const NETWORK_QUALITY_DISPLAY = {
excellent: {
label: '\u4f18\u79c0',
statusIconClass: 'fas fa-check-circle text-green-400',
statusTextClass: 'text-green-400',
headerIconClass: 'fas fa-signal text-green-400',
indicatorClass: 'w-2 h-2 bg-green-500 rounded-full animate-pulse',
connectionTextClass: 'text-green-400'
},
good: {
label: '\u826f\u597d',
statusIconClass: 'fas fa-signal text-blue-400',
statusTextClass: 'text-blue-400',
headerIconClass: 'fas fa-signal text-green-500',
indicatorClass: 'w-2 h-2 bg-green-500 rounded-full animate-pulse',
connectionTextClass: 'text-blue-400'
},
fair: {
label: '\u4e00\u822c',
statusIconClass: 'fas fa-exclamation-circle text-yellow-500',
statusTextClass: 'text-yellow-500',
headerIconClass: 'fas fa-signal text-yellow-400',
indicatorClass: 'w-2 h-2 bg-green-500 rounded-full animate-pulse',
connectionTextClass: 'text-yellow-500'
},
poor: {
label: '\u8f83\u5dee',
statusIconClass: 'fas fa-exclamation-triangle text-red-500',
statusTextClass: 'text-red-500',
headerIconClass: 'fas fa-signal text-red-400',
indicatorClass: 'w-2 h-2 bg-green-500 rounded-full animate-pulse',
connectionTextClass: 'text-red-500'
},
no_signal: {
label: '\u65e0\u4fe1\u53f7',
statusIconClass: 'fas fa-times-circle text-gray-500',
statusTextClass: 'text-gray-500',
headerIconClass: 'fas fa-signal text-gray-400',
indicatorClass: 'w-2 h-2 bg-gray-500 rounded-full',
connectionTextClass: 'text-gray-500'
}
};
function getRoleTagMarkup(user, role) {
if (role === 'local') {
return user.isHost
? '<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">\u4e3b\u6301\u4eba</span>'
: '<span class="text-xs bg-purple-500 px-1.5 rounded ml-1">\u53c2\u4e0e\u8005</span>';
}
if (role === 'participant') {
return '<span class="text-xs bg-purple-500 px-1.5 rounded ml-1">\u53c2\u4e0e\u8005</span>';
}
return '<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">\u4e3b\u6301\u4eba</span>';
}
function getDatasetUserId(role, id) {
switch (role) {
case 'local':
return 'local';
case 'remote':
return 'remote';
case 'host':
return `host_${id}`;
case 'participant':
return `participant_${id}`;
default:
return role;
}
}
function getAvatarMarkup(user, role) {
if (role === 'local') {
return `<img src="${user.avatar}" class="w-10 h-10 rounded-full object-cover">`;
}
return `
<div class="relative">
<img src="${user.avatar}" class="w-10 h-10 rounded-full object-cover">
<div class="absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-slate-900"></div>
</div>
`;
}
function getRightMarkup(mediaState, role, muteIconMarkup) {
if (role !== 'participant') {
return muteIconMarkup;
}
const speakingMarkup = (mediaState.isSpeaking && mediaState.audio)
? '<div class="audio-wave w-6"><span></span><span></span><span></span><span></span><span></span></div>'
: '';
return `
<div class="flex items-center gap-2">
${muteIconMarkup}
${speakingMarkup}
</div>
`;
}
export function getCallTitle(connectionId) {
return `\u901a\u8bdd (${connectionId || ''})`;
}
export function getRemoteVideoPlaceholderText(isVideoEnabled) {
return isVideoEnabled
? {
title: '\u7b49\u5f85\u5bf9\u65b9\u8fde\u63a5...',
subtitle: '\u8bf7\u786e\u8ba4\u5bf9\u65b9\u5df2\u52a0\u5165\u901a\u8bdd'
}
: {
title: '\u5bf9\u65b9\u6444\u50cf\u5934\u5df2\u5173\u95ed',
subtitle: '\u5bf9\u65b9\u6682\u65f6\u5173\u95ed\u4e86\u89c6\u9891'
};
}
export function getNetworkQualityDisplay(quality) {
return NETWORK_QUALITY_DISPLAY[quality] || DEFAULT_NETWORK_QUALITY;
}
export function getMediaStatusMeta(mediaState) {
if (!mediaState.audio) {
return {
text: '\u9759\u97f3\u4e2d',
className: 'text-xs text-gray-500',
muteIconClass: 'fas fa-microphone-slash text-gray-500 text-xs',
showMuteIcon: true
};
}
if (!mediaState.video) {
return {
text: '\u89c6\u9891\u5173\u95ed',
className: 'text-xs text-gray-500',
muteIconClass: 'fas fa-microphone-slash text-gray-500 text-xs',
showMuteIcon: false
};
}
return {
text: '\u5728\u7ebf',
className: 'text-xs text-green-400',
muteIconClass: 'fas fa-microphone-slash text-gray-500 text-xs',
showMuteIcon: false
};
}
export function buildUserCountLabel(userCount) {
return `\u901a\u8bdd\u6210\u5458 (${userCount})`;
}
export function createUserEntryElement({ user, role, id }) {
const entry = document.createElement('div');
const mediaMeta = getMediaStatusMeta(user.mediaState);
const muteIconMarkup = mediaMeta.showMuteIcon
? `<i class="${mediaMeta.muteIconClass}"></i>`
: '';
const baseClass = 'flex items-center gap-3 p-2 rounded-lg';
const dataFieldAttr = role === 'local' ? ' data-field="localUser.mediaStatus"' : '';
entry.className = role === 'local'
? `${baseClass} hover:bg-white/5`
: `${baseClass} bg-white/5`;
entry.dataset.userId = getDatasetUserId(role, id);
entry.innerHTML = `
${getAvatarMarkup(user, role)}
<div class="flex-1">
<div class="text-sm font-medium">
${user.name}
${getRoleTagMarkup(user, role)}
</div>
<div class="${mediaMeta.className}"${dataFieldAttr}>${mediaMeta.text}</div>
</div>
${getRightMarkup(user.mediaState, role, muteIconMarkup)}
`;
return entry;
}

View File

@@ -0,0 +1,541 @@
import { formatTime, formatTimestamp, toggleElement, toggleButtonState } from '../../shared/utils.js';
import { mockCallSession } from '../models.js';
import chatMessage from '../chat/chatmessage.js';
import store from '../store.js';
import {
buildUserCountLabel,
createUserEntryElement,
getCallTitle,
getMediaStatusMeta,
getNetworkQualityDisplay,
getRemoteVideoPlaceholderText
} from './renderer-ui.js';
import { renderChatMessagesInto } from '../chat/renderer-chat.js';
import {
updateParticipantTileName as syncParticipantTileName,
updateParticipantTilePlaceholder
} from '../participants/renderer-participant-grid.js';
import {
adjustVideoSize,
clearParticipantGrid,
getVideoResolution,
removeParticipantTile,
renderParticipantStreamMedia,
renderSingleRemoteStreamMedia
} from '../media/renderer-media.js';
import { createLogger } from '../../shared/logger.js';
const logger = createLogger('renderer');
const GRID_LAYOUT = {
maxColumns: 3,
breakpoints: [
{ maxParticipants: 1, template: '1fr' },
{ maxParticipants: 4, template: 'repeat(2, 1fr)' }
],
defaultTemplate: 'repeat(3, 1fr)'
};
function getGridTemplateColumns(participantCount) {
for (const bp of GRID_LAYOUT.breakpoints) {
if (participantCount <= bp.maxParticipants) {
return bp.template;
}
}
return GRID_LAYOUT.defaultTemplate;
}
class UIRenderer {
constructor(stateManager) {
this.stateManager = stateManager;
this.unsubscribe = stateManager.subscribe(this.render.bind(this));
this.elements = {
header: document.querySelector('header'),
footer: document.querySelector('footer'),
participantGrid: document.getElementById('participantGrid'),
headerTitle: document.getElementById('headerTitle'),
callDuration: document.getElementById('callDuration'),
encryptionBadge: document.getElementById('encryptionBadge'),
unreadBadge: document.getElementById('unreadBadge'),
remoteNetworkIndicator: document.getElementById('remoteNetworkIndicator'),
remoteNetworkQuality: document.getElementById('remoteNetworkQuality'),
remoteVideo: document.getElementById('remoteVideo'),
remoteVideoPlaceholder: document.getElementById('remoteVideoPlaceholder'),
networkStatus: document.getElementById('networkStatus'),
networkStatusText: document.getElementById('networkStatusText'),
connectingOverlay: document.getElementById('connectingOverlay'),
localVideo: document.getElementById('localVideo'),
localVideoPlaceholder: document.getElementById('localVideoPlaceholder'),
localAudioWave: document.getElementById('localAudioWave'),
localInitials: document.getElementById('localInitials'),
sidebar: document.getElementById('sidebar'),
chatContent: document.getElementById('chatContent'),
userList: document.getElementById('userList'),
localMediaStatus: document.getElementById('localMediaStatus'),
localMuteIcon: document.querySelector('[data-field="localUser.muteIcon"]'),
userCountDisplay: document.getElementById('userCountDisplay'),
micBtn: document.getElementById('micBtn'),
videoBtn: document.getElementById('videoBtn'),
recordBtn: document.getElementById('recordBtn'),
connectionQuality: document.getElementById('connectionQuality')
};
this.bindEventListeners();
this.unsubscribe = stateManager.subscribe(this.render.bind(this));
this.messageUnsubscribe = chatMessage.subscribe(this.renderMessageState.bind(this));
this.render(this.stateManager.getState(), { type: 'INIT' });
window.addEventListener('resize', () => {
if (this.elements.remoteVideo && this.elements.remoteVideo.srcObject) {
const stream = this.elements.remoteVideo.srcObject;
const videoTracks = stream.getVideoTracks();
if (videoTracks.length > 0) {
adjustVideoSize(this.elements.remoteVideo, getVideoResolution(videoTracks[0]));
}
}
});
}
renderMessageState(messageState, changes) {
switch (changes.type) {
case 'NEW_MESSAGE':
this.renderChatMessages(messageState.messages);
this.renderUnreadCount(changes.unreadCount);
break;
case 'SIDEBAR_TOGGLE':
this.renderSidebar(changes.isOpen);
if (changes.isOpen) {
this.renderUnreadCount(0);
} else {
this.renderUnreadCount(changes.unreadCount);
}
break;
}
}
bindEventListeners() {
}
/**
* 婵炴挸寮堕悡瀣棘鐟欏嫮銆?- 闁哄秷顫夊畵渚€鎮╅懜纰樺亾娴g缍侀柛鏍ㄧ墬濞插潡寮惂鐕?
* @param {Object} state - 鐟滅増鎸告晶鐘虫償閺冨倹鏆忛柣妯垮煐閳?
* @param {Object} changes - 闁绘鍩栭埀顑跨瑜板宕犻弽褜鍤犻悹?
*/
render(state, changes) {
switch (changes.type) {
case 'INIT':
this.renderRemoteVideo(state.session.remoteUser);
this.renderLocalVideo(state.session.localUser, state.localStream);
this.renderControlButtons(state.session.localUser.mediaState);
this.renderChatMessages(chatMessage.getMessageState().messages);
this.renderUserList(state.session.localUser, state.session.remoteUser, state.participants);
this.renderHeader(state.session);
if (this.elements.remoteVideoPlaceholder) {
if (state.remoteStream) {
this.elements.remoteVideoPlaceholder.classList.add('hidden');
} else {
this.elements.remoteVideoPlaceholder.classList.remove('hidden');
}
}
break;
case 'DURATION_UPDATE':
this.renderCallDuration(changes.duration);
break;
case 'LOCAL_MEDIA_CHANGE':
this.renderControlButtons(state.session.localUser.mediaState);
this.renderLocalVideo(state.session.localUser, state.localStream);
this.renderLocalUserStatus(state.session.localUser);
this.renderUserList(state.session.localUser, state.session.remoteUser, state.participants);
break;
case 'LOCAL_STREAM_OBTAINED':
this.renderLocalStream(state.localStream);
this.renderLocalVideo(state.session.localUser, state.localStream);
break;
case 'REMOTE_STREAM_OBTAINED':
this.renderRemoteStream(changes.stream, changes.connectionId, changes.isHost);
if (this.elements.connectingOverlay) {
this.elements.connectingOverlay.classList.add('hidden');
}
break;
case 'REMOTE_MEDIA_CHANGE':
this.renderRemoteVideo(state.session.remoteUser);
this.renderUserList(state.session.localUser, state.session.remoteUser, state.participants);
if (changes.participantId && state.session.localUser.isHost) {
const pInfo = state.participants[changes.participantId];
const showPlaceholder = pInfo ? !pInfo.mediaState.video : true;
this.renderParticipantVideoPlaceholder(changes.participantId, showPlaceholder);
}
break;
case 'USER_LIST_UPDATE':
this.renderUserList(changes.localUser, changes.remoteUser, state.participants);
break;
case 'PARTICIPANTS_UPDATE':
this.renderUserList(state.session.localUser, state.session.remoteUser, changes.participants || state.participants);
this.syncParticipantTileNames(changes.participants || state.participants);
break;
case 'NETWORK_CHANGE':
this.renderNetworkStatus(changes.quality);
break;
case 'CALL_STATUS_CHANGE':
this.renderCallStatus(changes.status);
break;
case 'CALL_ENDED':
this.renderCallEnded();
break;
case 'PARTICIPANT_LEFT':
this.renderParticipantLeft(changes.connectionId);
break;
case 'RESOLUTION_CHANGED':
this.renderResolutionChanged(changes.resolution);
break;
}
}
renderCallStatus(status) {
if (this.elements.connectingOverlay) {
if (status === 'connecting') {
this.elements.connectingOverlay.classList.remove('hidden');
} else {
this.elements.connectingOverlay.classList.add('hidden');
}
}
}
renderHeader(session) {
this.renderHeaderTitle();
if (this.elements.encryptionBadge) {
toggleElement(this.elements.encryptionBadge, session.isEncrypted);
}
if (this.elements.remoteNetworkIndicator) {
this.elements.remoteNetworkIndicator.classList.remove('hidden');
}
if (this.elements.remoteNetworkQuality) {
this.elements.remoteNetworkQuality.classList.remove('hidden');
}
this.renderCallDuration(session.duration);
}
renderHeaderTitle() {
if (this.elements.headerTitle) {
const connectionId = store.getConnectionId() || '';
this.elements.headerTitle.textContent = getCallTitle(connectionId);
}
}
renderCallDuration(duration) {
if (this.elements.callDuration) {
this.elements.callDuration.textContent = formatTime(duration);
}
}
renderResolutionChanged(resolution) {
if (!resolution) return;
const options = document.querySelectorAll('.resolution-option');
options.forEach(btn => {
const btnRes = btn.dataset.resolution;
const isActive = (resolution.height >= 1440 && btnRes === '1440') ||
(resolution.height >= 1080 && resolution.height < 1440 && btnRes === '1080') ||
(resolution.height >= 720 && resolution.height < 1080 && btnRes === '720') ||
(resolution.height < 720 && btnRes === '480');
btn.classList.toggle('active', isActive);
});
const currentResText = document.getElementById('currentResolutionText');
if (currentResText) {
currentResText.textContent = `当前: ${resolution.label}`;
}
}
renderRemoteVideo(remoteUser) {
this.renderUserList(
this.stateManager.getState().session.localUser,
remoteUser,
this.stateManager.getState().participants
);
if (this.elements.remoteVideoPlaceholder) {
const shouldShowPlaceholder = !remoteUser.mediaState.video;
const placeholderText = getRemoteVideoPlaceholderText(!shouldShowPlaceholder);
toggleElement(this.elements.remoteVideoPlaceholder, shouldShowPlaceholder);
if (this.elements.remoteVideo) {
this.elements.remoteVideo.style.opacity = shouldShowPlaceholder ? '0' : '1';
}
const placeholderContent = this.elements.remoteVideoPlaceholder.querySelector('.text-center');
if (placeholderContent) {
const titleElement = placeholderContent.querySelector('p.text-white.text-lg.font-medium');
const subtitleElement = placeholderContent.querySelector('p.text-sm.text-gray-400');
if (titleElement) {
titleElement.textContent = placeholderText.title;
}
if (subtitleElement) {
subtitleElement.textContent = placeholderText.subtitle;
}
}
}
this.renderNetworkStatus(remoteUser.networkQuality);
this.renderHeaderNetworkStatus(remoteUser.networkQuality);
}
renderHeaderNetworkStatus(networkQuality) {
if (this.elements.remoteNetworkQuality) {
const textElement = this.elements.remoteNetworkQuality.querySelector('span');
const iconElement = this.elements.remoteNetworkQuality.querySelector('i');
if (textElement && iconElement) {
const display = getNetworkQualityDisplay(networkQuality);
textElement.textContent = display.label;
iconElement.className = display.headerIconClass;
}
}
}
renderLocalVideo(localUser, localStream) {
if (this.elements.localVideoPlaceholder) {
const shouldShowPlaceholder = !localStream || !localUser.mediaState.video;
toggleElement(this.elements.localVideoPlaceholder, shouldShowPlaceholder);
}
if (this.elements.localAudioWave) {
toggleElement(this.elements.localAudioWave, localUser.mediaState.isSpeaking);
}
this.renderLocalUserStatus(localUser);
}
renderLocalStream(stream) {
if (this.elements.localVideo && stream) {
this.elements.localVideo.srcObject = stream;
this.elements.localVideo.autoplay = true;
this.elements.localVideo.muted = true;
logger.debug('srcObject set successfully:', this.elements.localVideo.srcObject);
if (this.elements.disconnectedOverlay) {
this.elements.disconnectedOverlay.classList.add('hidden');
}
} else {
logger.error('Either localVideo element or stream is missing');
}
}
renderRemoteStream(stream, connectionId, isHost) {
if (isHost && connectionId) {
this.renderParticipantStream(stream, connectionId);
} else {
this.renderSingleRemoteStream(stream);
}
}
renderParticipantStream(stream, connectionId) {
const participantInfo = this.stateManager.getState().participants[connectionId];
renderParticipantStreamMedia({
grid: this.elements.participantGrid,
stream,
connectionId,
displayName: participantInfo?.name,
getGridTemplateColumns,
remoteVideo: this.elements.remoteVideo,
connectingOverlay: this.elements.connectingOverlay,
remoteVideoPlaceholder: this.elements.remoteVideoPlaceholder
});
}
renderParticipantVideoPlaceholder(participantId, showPlaceholder) {
const grid = this.elements.participantGrid;
if (!grid) return;
updateParticipantTilePlaceholder(grid, participantId, showPlaceholder);
logger.debug(`Updated placeholder for participant ${participantId}: ${showPlaceholder ? 'shown' : 'hidden'}`);
}
syncParticipantTileNames(participants) {
if (!participants) return;
const grid = this.elements.participantGrid;
if (!grid) return;
for (const [participantId, pInfo] of Object.entries(participants)) {
this.updateParticipantTileName(participantId, pInfo.name);
}
}
updateParticipantTileName(participantId, name) {
const grid = this.elements.participantGrid;
if (!grid) return;
syncParticipantTileName(grid, participantId, name);
if (name) {
logger.debug(`Updated tile name for participant ${participantId}: ${name}`);
}
}
renderSingleRemoteStream(stream) {
renderSingleRemoteStreamMedia({
remoteVideo: this.elements.remoteVideo,
stream,
disconnectedOverlay: this.elements.disconnectedOverlay,
remoteVideoPlaceholder: this.elements.remoteVideoPlaceholder,
connectingOverlay: this.elements.connectingOverlay
});
}
renderLocalUserStatus(localUser) {
const mediaMeta = getMediaStatusMeta(localUser.mediaState);
if (this.elements.localMediaStatus) {
this.elements.localMediaStatus.textContent = mediaMeta.text;
this.elements.localMediaStatus.className = mediaMeta.className;
}
if (this.elements.localMuteIcon) {
if (mediaMeta.showMuteIcon) {
this.elements.localMuteIcon.classList.remove('hidden');
this.elements.localMuteIcon.className = mediaMeta.muteIconClass;
} else {
this.elements.localMuteIcon.classList.add('hidden');
}
}
}
renderUserList(localUser, remoteUser, participants) {
if (!this.elements.userList) return;
const participantsMap = participants || {};
const participantCount = Object.keys(participantsMap).length;
const userCount = 1 + participantCount;
if (this.elements.userCountDisplay) {
this.elements.userCountDisplay.textContent = buildUserCountLabel(userCount);
}
this.elements.userList.innerHTML = '';
this.elements.userList.appendChild(createUserEntryElement({
user: localUser,
role: 'local'
}));
if (participantCount > 0) {
for (const [participantId, participant] of Object.entries(participantsMap)) {
this.elements.userList.appendChild(createUserEntryElement({
user: participant,
role: participant.role === 'host' ? 'host' : 'participant',
id: participantId
}));
}
} else if (remoteUser.status !== 'offline') {
this.elements.userList.appendChild(createUserEntryElement({
user: remoteUser,
role: 'remote'
}));
}
}
renderControlButtons(mediaState) {
if (this.elements.micBtn) {
toggleButtonState(this.elements.micBtn, !mediaState.audio);
}
if (this.elements.videoBtn) {
toggleButtonState(this.elements.videoBtn, !mediaState.video);
}
if (this.elements.recordBtn) {
toggleButtonState(this.elements.recordBtn, mediaState.recording);
}
}
renderChatMessages(messages) {
renderChatMessagesInto(this.elements.chatContent, messages, formatTimestamp);
}
renderUnreadCount(count) {
if (this.elements.unreadBadge) {
if (count > 0) {
this.elements.unreadBadge.textContent = count;
this.elements.unreadBadge.classList.remove('hidden');
} else {
this.elements.unreadBadge.classList.add('hidden');
}
}
}
renderSidebar(isOpen) {
if (this.elements.sidebar) {
if (isOpen) {
this.elements.sidebar.classList.remove('hidden');
} else {
this.elements.sidebar.classList.add('hidden');
}
}
}
renderNetworkStatus(quality) {
const display = getNetworkQualityDisplay(quality);
if (this.elements.networkStatus && this.elements.networkStatusText) {
toggleElement(this.elements.networkStatus, true);
const networkStatus = this.elements.networkStatus;
const networkStatusText = this.elements.networkStatusText;
const existingIcon = networkStatus.querySelector('i');
if (existingIcon) {
existingIcon.remove();
}
const icon = document.createElement('i');
icon.className = display.statusIconClass;
networkStatusText.textContent = display.label;
networkStatusText.className = display.statusTextClass;
networkStatus.insertBefore(icon, networkStatusText);
}
if (this.elements.connectionQuality) {
this.elements.connectionQuality.textContent = `连接质量: ${display.label}`;
this.elements.connectionQuality.className = `text-xs ${display.connectionTextClass}`;
}
this.updateHeaderNetworkIndicator(quality);
this.renderHeaderNetworkStatus(quality);
}
updateHeaderNetworkIndicator(networkQuality) {
if (!this.elements.remoteNetworkIndicator) return;
this.elements.remoteNetworkIndicator.className = getNetworkQualityDisplay(networkQuality).indicatorClass;
}
renderCallEnded() {
logger.debug('Call ended');
clearParticipantGrid(this.elements.participantGrid);
window.location.href = '/endcall/';
}
renderParticipantLeft(connectionId) {
logger.debug(`Participant left: ${connectionId}, updating UI`);
removeParticipantTile({
grid: this.elements.participantGrid,
connectionId,
getGridTemplateColumns,
remoteVideo: this.elements.remoteVideo,
remoteVideoPlaceholder: this.elements.remoteVideoPlaceholder,
remoteNetworkIndicator: this.elements.remoteNetworkIndicator
});
}
destroy() {
if (this.unsubscribe) {
this.unsubscribe();
}
if (this.messageUnsubscribe) {
this.messageUnsubscribe();
}
}
}
export default UIRenderer;

View File

@@ -0,0 +1,159 @@
const EMPTY_CONNECTION_IDS_HTML = '<p class="text-gray-500 text-sm">\u6682\u65e0\u53ef\u7528\u7684\u8fde\u63a5ID</p>';
const EMPTY_USERS_HTML = '<p class="text-gray-500 text-sm">\u6682\u65e0\u5728\u7ebf\u7528\u6237</p>';
const HALL_LABEL = '\u5927\u5385\uff08\u672a\u52a0\u5165\u623f\u95f4\uff09';
const HOST_LABEL = '\u623f\u4e3b';
const PARTICIPANT_LABEL = '\u6210\u5458';
const UNKNOWN_USER_LABEL = '\u533f\u540d\u7528\u6237';
const UNSET_USER_ID_LABEL = '\u672a\u8bbe\u7f6eID';
const SELF_LABEL = '\u81ea\u5df1';
const SELECT_LABEL = '\u9009\u62e9';
const USER_COUNT_SUFFIX = '\u4eba';
const ONLINE_USERS_SUMMARY_SUFFIX = ' \u4e2aWebSocket\u7528\u6237\u5728\u7ebf';
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export async function fetchOnlineUsers() {
const response = await fetch('/signaling/users');
if (!response.ok) {
throw new Error('Failed to fetch online users');
}
const data = await response.json();
return Array.isArray(data.users) ? data.users : [];
}
export async function fetchConnectionDirectory() {
const [connectionResponse, usersResponse] = await Promise.all([
fetch('/signaling/connection-ids'),
fetch('/signaling/users')
]);
if (!connectionResponse.ok) {
throw new Error('Failed to fetch connection IDs');
}
if (!usersResponse.ok) {
throw new Error('Failed to fetch online users');
}
const connectionData = await connectionResponse.json();
const usersData = await usersResponse.json();
return {
connectionIds: connectionData.connectionIds || [],
users: Array.isArray(usersData.users) ? usersData.users : []
};
}
export function renderConnectionIds({ connectionIds, idsContainer, connectionIdsList, onSelectConnectionId }) {
if (!idsContainer) {
return;
}
idsContainer.innerHTML = '';
if (connectionIds.length === 0) {
idsContainer.innerHTML = EMPTY_CONNECTION_IDS_HTML;
} else {
connectionIds.forEach((connectionId) => {
const item = document.createElement('div');
item.className = 'flex items-center justify-between p-2 bg-white/5 rounded-lg hover:bg-white/10 cursor-pointer transition-colors';
const label = document.createElement('span');
label.className = 'text-sm';
label.textContent = connectionId;
const button = document.createElement('button');
button.className = 'text-xs bg-indigo-600 hover:bg-indigo-700 px-2 py-1 rounded';
button.type = 'button';
button.textContent = SELECT_LABEL;
button.addEventListener('click', () => onSelectConnectionId(connectionId));
item.appendChild(label);
item.appendChild(button);
idsContainer.appendChild(item);
});
}
if (connectionIdsList) {
connectionIdsList.classList.remove('hidden');
}
}
export function renderOnlineUsers({ users, currentUserId, onlineUsersList, usersContainer, onlineUsersSummary }) {
if (!onlineUsersList || !usersContainer || !onlineUsersSummary) {
return;
}
onlineUsersSummary.textContent = `${users.length}${ONLINE_USERS_SUMMARY_SUFFIX}`;
usersContainer.innerHTML = '';
if (users.length === 0) {
usersContainer.innerHTML = EMPTY_USERS_HTML;
onlineUsersList.classList.remove('hidden');
return;
}
const groupedUsers = users.reduce((groups, user) => {
const groupName = user.connectionId ? `\u623f\u95f4 ${user.connectionId}` : HALL_LABEL;
if (!groups[groupName]) {
groups[groupName] = [];
}
groups[groupName].push(user);
return groups;
}, {});
Object.entries(groupedUsers).forEach(([groupName, roomUsers]) => {
const section = document.createElement('div');
section.className = 'rounded-lg border border-white/10 bg-white/5 p-3';
const roomTitle = document.createElement('div');
roomTitle.className = 'flex items-center justify-between mb-2';
roomTitle.innerHTML = `
<span class="text-sm font-medium text-white">${escapeHtml(groupName)}</span>
<span class="text-xs text-gray-400">${roomUsers.length} ${USER_COUNT_SUFFIX}</span>
`;
section.appendChild(roomTitle);
const roomList = document.createElement('div');
roomList.className = 'space-y-2';
roomUsers.forEach((user) => {
const userName = user.name || user.userId || UNKNOWN_USER_LABEL;
const avatar = user.avatar || '/images/p2.png';
const roleLabel = user.role === 'host'
? HOST_LABEL
: (user.role === 'participant' ? PARTICIPANT_LABEL : HALL_LABEL);
const isSelf = Boolean(user.userId) && user.userId === currentUserId;
const identity = user.userId || user.socketId || user.participantId || UNSET_USER_ID_LABEL;
const userItem = document.createElement('div');
userItem.className = 'flex items-center justify-between rounded-lg bg-black/20 px-3 py-2';
userItem.innerHTML = `
<div class="flex items-center gap-3 min-w-0">
<img src="${escapeHtml(avatar)}" alt="${escapeHtml(userName)}" class="w-8 h-8 rounded-full object-cover">
<div class="min-w-0">
<div class="text-sm text-white truncate">${escapeHtml(userName)}</div>
<div class="text-xs text-gray-400 truncate">${escapeHtml(identity)}</div>
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-xs px-2 py-1 rounded-full ${user.role === 'host' ? 'bg-indigo-500/20 text-indigo-300' : (user.role === 'participant' ? 'bg-white/10 text-gray-300' : 'bg-emerald-500/20 text-emerald-300')}">${roleLabel}</span>
${isSelf ? `<span class="text-xs text-gray-500">${SELF_LABEL}</span>` : ''}
</div>
`;
roomList.appendChild(userItem);
});
section.appendChild(roomList);
usersContainer.appendChild(section);
});
onlineUsersList.classList.remove('hidden');
}

View File

@@ -0,0 +1,102 @@
import { Signaling, WebSocketSignaling } from '/module/core/signaling.js';
import { createLogger } from '../../shared/logger.js';
const logger = createLogger('signaling');
const INVITE_EVENT_NAMES = Object.freeze([
'invite-call',
'invite-accepted',
'invite-rejected',
'invite-failed'
]);
const DEFAULT_SOCKET_USER_NAME = '?';
const DEFAULT_SOCKET_USER_AVATAR = '/images/p1.png';
export function createSignalingInstance(useWebSocket) {
return useWebSocket ? new WebSocketSignaling() : new Signaling();
}
export async function ensureSignalingStarted(existingSignaling, useWebSocket) {
if (existingSignaling) {
return {
signaling: existingSignaling,
reused: true
};
}
const signaling = createSignalingInstance(useWebSocket);
await signaling.start();
return {
signaling,
reused: false
};
}
export function bindInviteSocketEvents(signaling, eventHandlers, boundSignaling = null) {
if (!signaling || signaling === boundSignaling || typeof signaling.addEventListener !== 'function') {
return boundSignaling;
}
INVITE_EVENT_NAMES.forEach((eventName) => {
signaling.addEventListener(eventName, (event) => {
const handler = eventHandlers[eventName];
if (typeof handler === 'function') {
handler(event.detail);
}
});
});
return signaling;
}
export function getActiveSignalingInstance(preconnectedSignaling, renderstreaming) {
if (preconnectedSignaling) {
return preconnectedSignaling;
}
if (renderstreaming && renderstreaming._signaling) {
return renderstreaming._signaling;
}
return null;
}
export function sendInviteSignal(signaling, methodName, payload) {
if (!signaling || typeof signaling[methodName] !== 'function') {
throw new Error('Invite signaling is not ready');
}
signaling[methodName](payload);
}
function readStoredSocketUserInfo() {
try {
return JSON.parse(localStorage.getItem('userSettings') || '{}');
} catch (error) {
logger.error('Error parsing user settings:', error);
return {};
}
}
export function buildSocketUserInfoPayload(userInfo, localUser) {
const settings = userInfo || readStoredSocketUserInfo();
return {
id: settings.id || settings.userId || localUser.id || '',
name: settings.name || localUser.name || DEFAULT_SOCKET_USER_NAME,
avatar: settings.avatar || localUser.avatar || DEFAULT_SOCKET_USER_AVATAR
};
}
export function sendSocketUserInfo(signaling, payload) {
if (!signaling || typeof signaling.sendMessage !== 'function') {
return;
}
signaling.sendMessage('', {
type: 'user-info',
data: payload
});
}

1098
client/public/call/store.js Normal file

File diff suppressed because it is too large Load Diff