2026-03-03 17:51:30 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* UI渲染器
|
|
|
|
|
|
* 负责将状态映射到DOM,与状态管理解耦
|
|
|
|
|
|
*/
|
|
|
|
|
|
import { formatTime, formatTimestamp, toggleElement, toggleButtonState } from './utils.js';
|
|
|
|
|
|
|
|
|
|
|
|
class UIRenderer {
|
|
|
|
|
|
constructor(stateManager) {
|
|
|
|
|
|
this.stateManager = stateManager;
|
|
|
|
|
|
this.unsubscribe = stateManager.subscribe(this.render.bind(this));
|
|
|
|
|
|
|
|
|
|
|
|
// 缓存 DOM 元素
|
|
|
|
|
|
this.elements = {
|
|
|
|
|
|
// 头部
|
|
|
|
|
|
headerTitle: document.getElementById('headerTitle'),
|
|
|
|
|
|
callDuration: document.getElementById('callDuration'),
|
|
|
|
|
|
encryptionBadge: document.getElementById('encryptionBadge'),
|
|
|
|
|
|
unreadBadge: document.getElementById('unreadBadge'),
|
|
|
|
|
|
|
|
|
|
|
|
// 远端视频
|
|
|
|
|
|
remoteVideo: document.getElementById('remoteVideo'),
|
|
|
|
|
|
remoteAvatar: document.getElementById('remoteAvatar'),
|
|
|
|
|
|
remoteName: document.getElementById('remoteName'),
|
|
|
|
|
|
remoteStatus: document.getElementById('remoteStatus'),
|
|
|
|
|
|
remoteSpeakingIndicator: document.getElementById('remoteSpeakingIndicator'),
|
|
|
|
|
|
remoteAudioWave: document.getElementById('remoteAudioWave'),
|
|
|
|
|
|
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'),
|
2026-03-03 18:06:20 +08:00
|
|
|
|
localMediaStatus: document.getElementById('localMediaStatus'),
|
|
|
|
|
|
localMuteIcon: document.querySelector('[data-field="localUser.muteIcon"]'),
|
2026-03-03 17:51:30 +08:00
|
|
|
|
|
|
|
|
|
|
// 控制按钮
|
|
|
|
|
|
micBtn: document.getElementById('micBtn'),
|
|
|
|
|
|
videoBtn: document.getElementById('videoBtn'),
|
|
|
|
|
|
recordBtn: document.getElementById('recordBtn'),
|
|
|
|
|
|
connectionQuality: document.getElementById('connectionQuality')
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化渲染
|
|
|
|
|
|
this.render(this.stateManager.getState(), { type: 'INIT' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染状态变化
|
|
|
|
|
|
render(state, changes) {
|
|
|
|
|
|
switch (changes.type) {
|
|
|
|
|
|
case 'INIT':
|
|
|
|
|
|
this.renderHeader(state.session);
|
|
|
|
|
|
this.renderRemoteVideo(state.session.remoteUser);
|
|
|
|
|
|
this.renderLocalVideo(state.session.localUser);
|
|
|
|
|
|
this.renderControlButtons(state.session.localUser.mediaState);
|
|
|
|
|
|
this.renderChatMessages(state.messages);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'DURATION_UPDATE':
|
|
|
|
|
|
this.renderCallDuration(changes.duration);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'LOCAL_MEDIA_CHANGE':
|
|
|
|
|
|
this.renderControlButtons(state.session.localUser.mediaState);
|
|
|
|
|
|
this.renderLocalVideo(state.session.localUser);
|
2026-03-03 18:06:20 +08:00
|
|
|
|
this.renderLocalUserStatus(state.session.localUser);
|
2026-03-03 17:51:30 +08:00
|
|
|
|
break;
|
2026-03-04 11:19:50 +08:00
|
|
|
|
case 'LOCAL_STREAM_OBTAINED':
|
|
|
|
|
|
this.renderLocalStream(state.localStream);
|
|
|
|
|
|
break;
|
2026-03-03 17:51:30 +08:00
|
|
|
|
case 'REMOTE_MEDIA_CHANGE':
|
|
|
|
|
|
this.renderRemoteVideo(state.session.remoteUser);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'NEW_MESSAGE':
|
|
|
|
|
|
this.renderChatMessages(state.messages);
|
|
|
|
|
|
this.renderUnreadCount(changes.unreadCount);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'SIDEBAR_TOGGLE':
|
|
|
|
|
|
this.renderSidebar(changes.isOpen);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'NETWORK_CHANGE':
|
|
|
|
|
|
this.renderNetworkStatus(changes.quality);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'CALL_ENDED':
|
|
|
|
|
|
this.renderCallEnded();
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染头部
|
|
|
|
|
|
renderHeader(session) {
|
|
|
|
|
|
if (this.elements.headerTitle) {
|
|
|
|
|
|
this.elements.headerTitle.textContent = `与 ${session.remoteUser.name} 的通话`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (this.elements.encryptionBadge) {
|
|
|
|
|
|
toggleElement(this.elements.encryptionBadge, session.isEncrypted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.renderCallDuration(session.duration);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染通话时长
|
|
|
|
|
|
renderCallDuration(duration) {
|
|
|
|
|
|
if (this.elements.callDuration) {
|
|
|
|
|
|
this.elements.callDuration.textContent = formatTime(duration);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染远端视频
|
|
|
|
|
|
renderRemoteVideo(remoteUser) {
|
|
|
|
|
|
if (this.elements.remoteName) {
|
|
|
|
|
|
this.elements.remoteName.textContent = remoteUser.name;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (this.elements.remoteAvatar) {
|
|
|
|
|
|
this.elements.remoteAvatar.src = remoteUser.avatar;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (this.elements.remoteStatus) {
|
|
|
|
|
|
this.elements.remoteStatus.textContent = this.getStatusText(remoteUser.status);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染说话状态
|
|
|
|
|
|
if (this.elements.remoteSpeakingIndicator) {
|
|
|
|
|
|
toggleElement(this.elements.remoteSpeakingIndicator, remoteUser.mediaState.isSpeaking);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (this.elements.remoteAudioWave) {
|
|
|
|
|
|
toggleElement(this.elements.remoteAudioWave, remoteUser.mediaState.isSpeaking && remoteUser.mediaState.audio);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染网络状态
|
|
|
|
|
|
this.renderNetworkStatus(remoteUser.networkQuality);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染本地视频
|
|
|
|
|
|
renderLocalVideo(localUser) {
|
|
|
|
|
|
if (this.elements.localVideoPlaceholder) {
|
|
|
|
|
|
toggleElement(this.elements.localVideoPlaceholder, !localUser.mediaState.video);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (this.elements.localAudioWave) {
|
|
|
|
|
|
toggleElement(this.elements.localAudioWave, localUser.mediaState.isSpeaking);
|
|
|
|
|
|
}
|
2026-03-03 18:06:20 +08:00
|
|
|
|
|
|
|
|
|
|
// 同时渲染本地用户状态
|
|
|
|
|
|
this.renderLocalUserStatus(localUser);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 11:19:50 +08:00
|
|
|
|
// 渲染本地视频流
|
|
|
|
|
|
renderLocalStream(stream) {
|
|
|
|
|
|
if (this.elements.localVideo && stream) {
|
|
|
|
|
|
this.elements.localVideo.srcObject = stream;
|
|
|
|
|
|
this.elements.localVideo.autoplay = true;
|
|
|
|
|
|
this.elements.localVideo.muted = true; // 本地视频静音,避免回声
|
|
|
|
|
|
console.log('srcObject set successfully:', this.elements.localVideo.srcObject);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.error('Either localVideo element or stream is missing');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 18:06:20 +08:00
|
|
|
|
// 渲染本地用户状态
|
|
|
|
|
|
renderLocalUserStatus(localUser) {
|
|
|
|
|
|
// 更新本地媒体状态文本
|
|
|
|
|
|
if (this.elements.localMediaStatus) {
|
|
|
|
|
|
if (!localUser.mediaState.audio) {
|
|
|
|
|
|
this.elements.localMediaStatus.textContent = '静音中';
|
|
|
|
|
|
this.elements.localMediaStatus.className = 'text-xs text-gray-500';
|
|
|
|
|
|
} else if (!localUser.mediaState.video) {
|
|
|
|
|
|
this.elements.localMediaStatus.textContent = '视频关闭';
|
|
|
|
|
|
this.elements.localMediaStatus.className = 'text-xs text-gray-500';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.elements.localMediaStatus.textContent = '在线';
|
|
|
|
|
|
this.elements.localMediaStatus.className = 'text-xs text-green-400';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新静音图标
|
|
|
|
|
|
if (this.elements.localMuteIcon) {
|
|
|
|
|
|
if (!localUser.mediaState.audio) {
|
|
|
|
|
|
this.elements.localMuteIcon.classList.remove('hidden');
|
|
|
|
|
|
this.elements.localMuteIcon.className = 'fas fa-microphone-slash text-gray-500 text-xs';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.elements.localMuteIcon.classList.add('hidden');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-03 17:51:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染控制按钮
|
|
|
|
|
|
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) {
|
|
|
|
|
|
if (!this.elements.chatContent) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 清空聊天内容
|
|
|
|
|
|
this.elements.chatContent.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 = `通话开始 ${formatTimestamp(startTime)}`;
|
|
|
|
|
|
this.elements.chatContent.appendChild(startTimeElement);
|
|
|
|
|
|
|
|
|
|
|
|
// 添加消息
|
|
|
|
|
|
messages.forEach(message => {
|
|
|
|
|
|
const messageElement = this.createMessageElement(message);
|
|
|
|
|
|
this.elements.chatContent.appendChild(messageElement);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 滚动到底部
|
|
|
|
|
|
this.elements.chatContent.scrollTop = this.elements.chatContent.scrollHeight;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 创建消息元素
|
|
|
|
|
|
createMessageElement(message) {
|
|
|
|
|
|
const messageDiv = document.createElement('div');
|
|
|
|
|
|
|
|
|
|
|
|
// 根据消息类型设置不同的CSS类
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
2026-03-03 18:06:20 +08:00
|
|
|
|
let contentHTML = '';
|
|
|
|
|
|
if (message.type === 'file' && message.content.startsWith('data:image/')) {
|
|
|
|
|
|
// 图片消息
|
|
|
|
|
|
contentHTML = `
|
|
|
|
|
|
<div class="message-image-container">
|
|
|
|
|
|
<img src="${message.content}" class="message-image" alt="${message.fileName || '图片'}">
|
|
|
|
|
|
${message.fileName ? `<div class="message-image-name">${message.fileName}</div>` : ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 文本消息
|
|
|
|
|
|
contentHTML = `
|
|
|
|
|
|
<div class="message-text">
|
|
|
|
|
|
${message.content}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 17:51:30 +08:00
|
|
|
|
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">
|
2026-03-03 18:06:20 +08:00
|
|
|
|
${contentHTML}
|
2026-03-03 17:51:30 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
return messageDiv;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染未读消息数
|
|
|
|
|
|
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) {
|
|
|
|
|
|
if (this.elements.networkStatus && this.elements.networkStatusText) {
|
|
|
|
|
|
const showNetworkStatus = quality !== 'excellent';
|
|
|
|
|
|
toggleElement(this.elements.networkStatus, showNetworkStatus);
|
|
|
|
|
|
|
|
|
|
|
|
if (showNetworkStatus) {
|
|
|
|
|
|
this.elements.networkStatusText.textContent = this.getNetworkQualityText(quality);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (this.elements.connectionQuality) {
|
|
|
|
|
|
this.elements.connectionQuality.textContent = `连接质量: ${this.getNetworkQualityText(quality)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染通话结束
|
|
|
|
|
|
renderCallEnded() {
|
|
|
|
|
|
// 可以在这里添加通话结束的UI处理
|
|
|
|
|
|
console.log('Call ended');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取状态文本
|
|
|
|
|
|
getStatusText(status) {
|
|
|
|
|
|
const statusMap = {
|
|
|
|
|
|
'online': '在线',
|
|
|
|
|
|
'offline': '离线',
|
|
|
|
|
|
'connecting': '连接中'
|
|
|
|
|
|
};
|
|
|
|
|
|
return statusMap[status] || status;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取网络质量文本
|
|
|
|
|
|
getNetworkQualityText(quality) {
|
|
|
|
|
|
const qualityMap = {
|
|
|
|
|
|
'excellent': '优秀',
|
|
|
|
|
|
'good': '良好',
|
|
|
|
|
|
'fair': '一般',
|
|
|
|
|
|
'poor': '较差'
|
|
|
|
|
|
};
|
|
|
|
|
|
return qualityMap[quality] || quality;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 销毁
|
|
|
|
|
|
destroy() {
|
|
|
|
|
|
if (this.unsubscribe) {
|
|
|
|
|
|
this.unsubscribe();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default UIRenderer;
|