Files
video_socket-server/client/public/renderer.js
2026-05-18 21:12:05 +08:00

1135 lines
47 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* UI渲染器
* 负责将状态映射到DOM与状态管理解耦
*/
import { formatTime, formatTimestamp, toggleElement, toggleButtonState } from './utils.js';
import { mockCallSession } from './models.js';
import chatMessage from './chatmessage.js';
import store from './store.js';
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));
// 缓存 DOM 元素
this.elements = {
// 头部和底部
header: document.querySelector('header'),
footer: document.querySelector('footer'),
// 多Participant视频网格
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) {
const resolution = this.getVideoResolution(videoTracks[0]);
this.adjustVideoSize(this.elements.remoteVideo, resolution);
}
}
});
}
// 渲染消息状态变化
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() {
// 事件监听器
}
/**
* 渲染方法 - 根据状态变化更新UI
* @param {Object} state - 当前应用状态
* @param {Object} changes - 状态变化对象
*/
render(state, changes) {
// 根据变化类型执行不同的渲染操作
switch (changes.type) {
case 'INIT':
// 初始化渲染 - 渲染所有UI元素
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':
// 本地媒体状态变化 - 更新相关UI
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); // 渲染用户列表
// Host端精准更新发送者participant tile的占位背景
// 只有Host端需要处理participantId对应的tile占位符Participant端没有其他Participant的视频流
if (changes.participantId && state.session.localUser.isHost) {
// 从participants Map中读取该participant的video状态而非remoteUser多Participant场景remoteUser不精确
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':
// Participants信息变化 - 重新渲染用户列表并同步tile名称
this.renderUserList(state.session.localUser, state.session.remoteUser, changes.participants || state.participants);
// 同步更新participant tile的名称标签
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':
// participant离开 - 更新UI但房间仍然存在
this.renderParticipantLeft(changes.connectionId);
break;
case 'RESOLUTION_CHANGED':
// 分辨率变化 - 更新UI中的分辨率选中状态
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 = `通话 (${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;
toggleElement(this.elements.remoteVideoPlaceholder, shouldShowPlaceholder);
// 当远程视频关闭时,隐藏视频元素本身,避免冻结画面透过占位符
if (this.elements.remoteVideo) {
if (shouldShowPlaceholder) {
this.elements.remoteVideo.style.opacity = '0';
} else {
this.elements.remoteVideo.style.opacity = '1';
}
}
// 更新占位符文本内容
if (shouldShowPlaceholder) {
const placeholderContent = this.elements.remoteVideoPlaceholder.querySelector('.text-center');
if (placeholderContent) {
const titleElement = placeholderContent.querySelector('p.text-white.text-lg.font-medium');
if (titleElement) {
titleElement.textContent = '对方摄像头已关闭';
}
const subtitleElement = placeholderContent.querySelector('p.text-sm.text-gray-400');
if (subtitleElement) {
subtitleElement.textContent = '对方暂时关闭了视频';
}
}
} else {
// 恢复默认占位符文本
const placeholderContent = this.elements.remoteVideoPlaceholder.querySelector('.text-center');
if (placeholderContent) {
const titleElement = placeholderContent.querySelector('p.text-white.text-lg.font-medium');
if (titleElement) {
titleElement.textContent = '等待对方连接...';
}
const subtitleElement = placeholderContent.querySelector('p.text-sm.text-gray-400');
if (subtitleElement) {
subtitleElement.textContent = '请确保对方已加入通话';
}
}
}
}
// 渲染网络状态
this.renderNetworkStatus(remoteUser.networkQuality);
// 渲染header中的网络状态
this.renderHeaderNetworkStatus(remoteUser.networkQuality);
}
// 渲染header中的网络状态
renderHeaderNetworkStatus(networkQuality) {
if (this.elements.remoteNetworkQuality) {
const textElement = this.elements.remoteNetworkQuality.querySelector('span');
const iconElement = this.elements.remoteNetworkQuality.querySelector('i');
if (textElement && iconElement) {
let qualityText = '未知';
let iconClass = 'fas fa-signal text-gray-400';
switch (networkQuality) {
case 'excellent':
qualityText = '优秀';
iconClass = 'fas fa-signal text-green-400';
break;
case 'good':
qualityText = '良好';
iconClass = 'fas fa-signal text-green-500';
break;
case 'fair':
qualityText = '一般';
iconClass = 'fas fa-signal text-yellow-400';
break;
case 'poor':
qualityText = '较差';
iconClass = 'fas fa-signal text-red-400';
break;
}
textElement.textContent = qualityText;
iconElement.className = iconClass;
}
}
}
// 渲染本地视频
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; // 本地视频静音,避免回声
console.log('srcObject set successfully:', this.elements.localVideo.srcObject);
// 隐藏断开连接覆盖层
if (this.elements.disconnectedOverlay) {
this.elements.disconnectedOverlay.classList.add('hidden');
}
} else {
console.error('Either localVideo element or stream is missing');
}
}
// 渲染远程视频流
renderRemoteStream(stream, connectionId, isHost) {
if (isHost && connectionId) {
// Host端: 渲染到 participant 视频网格
this.renderParticipantStream(stream, connectionId);
} else {
// Participant端: 渲染到单一远端视频Host的画面
this.renderSingleRemoteStream(stream);
}
}
// 渲染Host端的多Participant视频网格
// 每个participant tile显示该participant实际的远端视频流
renderParticipantStream(stream, connectionId) {
const grid = this.elements.participantGrid;
if (!grid) return;
// 显示网格,隐藏单路远端视频
grid.classList.remove('hidden');
// 查找或创建该 connectionId 的视频格子
let tile = grid.querySelector(`[data-participant-id="${connectionId}"]`);
if (!tile) {
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; // 不静音播放participant的音频
video.id = `participantVideo_${connectionId}`;
tile.appendChild(video);
// 参与者视频关闭时的占位背景(复用 remoteVideoPlaceholder 样式)
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">摄像头已关闭</p>
</div>
`;
tile.appendChild(placeholder);
// 参与者名称标签优先使用participants中的真实姓名
const pInfo = this.stateManager.getState().participants[connectionId];
const displayName = pInfo?.name || '参与者';
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}</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>在线</span>`;
tile.appendChild(liveTag);
grid.appendChild(tile);
console.log(`Created participant video tile for ${connectionId}`);
}
if (tile) {
// 视频元素显示participant的远端视频流
const video = tile.querySelector('video');
if (video && stream) {
// 避免重复设置同一流对象(音频先到、视频后到时流对象相同)
if (video.srcObject === stream) {
console.log(`Same stream for participant ${connectionId}, ensuring playback`);
video.play().catch(e => console.log('Auto-play prevented:', e.message));
} else {
video.srcObject = stream;
video.play().catch(e => console.log('Auto-play prevented:', e.message));
console.log(`Set remote stream for participant tile ${connectionId}`);
}
}
// 隐藏单路远端视频和占位符
const remoteVideoDiv = this.elements.remoteVideo?.closest('.absolute.inset-0.video-fade-in');
if (remoteVideoDiv) {
remoteVideoDiv.classList.add('hidden');
}
}
// 根据参与者数量调整网格列数
const tileCount = grid.querySelectorAll('[data-participant-id]').length;
grid.style.gridTemplateColumns = getGridTemplateColumns(tileCount);
// 隐藏连接中提示
if (this.elements.connectingOverlay) {
this.elements.connectingOverlay.classList.add('hidden');
}
// 隐藏远端视频占位符
if (this.elements.remoteVideoPlaceholder) {
this.elements.remoteVideoPlaceholder.classList.add('hidden');
}
}
// 精准更新指定participant tile的占位背景
// participantId: 发送media-state-changed的participant的连接ID
// showPlaceholder: 是否显示占位背景视频关闭时为true
renderParticipantVideoPlaceholder(participantId, showPlaceholder) {
const grid = this.elements.participantGrid;
if (!grid) return;
const tile = grid.querySelector(`[data-participant-id="${participantId}"]`);
if (!tile) return;
const placeholder = tile.querySelector('.participant-video-placeholder');
if (placeholder) {
toggleElement(placeholder, showPlaceholder);
console.log(`Updated placeholder for participant ${participantId}: ${showPlaceholder ? 'shown' : 'hidden'}`);
}
}
// 同步更新所有participant tile的名称标签
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);
}
}
// 更新指定participant tile的名称标签
updateParticipantTileName(participantId, name) {
const grid = this.elements.participantGrid;
if (!grid) return;
const tile = grid.querySelector(`[data-participant-id="${participantId}"]`);
if (!tile) return;
const label = tile.querySelector('.absolute.bottom-3');
if (label) {
const nameSpan = label.querySelector('span');
if (nameSpan && name) {
nameSpan.textContent = name;
console.log(`Updated tile name for participant ${participantId}: ${name}`);
}
}
}
// 渲染Participant端的单一远端视频Host画面
renderSingleRemoteStream(stream) {
if (!this.elements.remoteVideo || !stream) {
console.error('Either remoteVideo element or stream is missing');
return;
}
console.log('Rendering remote stream:', stream, 'tracks:', stream.getTracks().map(t => `${t.kind}(${t.readyState})`));
// 关键修复:避免 srcObject = null 的重置模式
// 如果 srcObject 已经是同一个 stream 对象,说明是同一流的轨道更新(如音频先到,视频后到)
// 浏览器会自动识别新添加的轨道,无需重置 srcObject
if (this.elements.remoteVideo.srcObject === stream) {
console.log('Same stream object, track added - ensuring playback');
this.elements.remoteVideo.play().catch(e => console.log('Auto-play prevented:', e.message));
return;
}
// 首次设置或流对象变化:直接设置 srcObject不使用 null 重置模式)
this.elements.remoteVideo.srcObject = stream;
this.elements.remoteVideo.autoplay = true;
this.elements.remoteVideo.playsinline = true;
this.elements.remoteVideo.muted = false;
// 确保视频开始播放
this.elements.remoteVideo.play().catch(e => {
console.log('Auto-play prevented, will retry on interaction:', e.message);
});
// 隐藏断开连接覆盖层
if (this.elements.disconnectedOverlay) {
this.elements.disconnectedOverlay.classList.add('hidden');
}
// 监听视频轨道变化
const videoTracks = stream.getVideoTracks();
const audioTracks = stream.getAudioTracks();
console.log(`Stream has ${videoTracks.length} video tracks, ${audioTracks.length} audio tracks`);
if (videoTracks.length > 0) {
// 有视频轨道:隐藏占位符
if (this.elements.remoteVideoPlaceholder) {
this.elements.remoteVideoPlaceholder.classList.add('hidden');
}
if (this.elements.connectingOverlay) {
this.elements.connectingOverlay.classList.add('hidden');
}
// 监听视频轨道分辨率变化
const activeVideoTrack = videoTracks.find(track => track.readyState === 'live');
if (activeVideoTrack) {
const resolution = this.getVideoResolution(activeVideoTrack);
this.adjustVideoSize(this.elements.remoteVideo, resolution);
activeVideoTrack.addEventListener('resize', () => {
const newResolution = this.getVideoResolution(activeVideoTrack);
this.adjustVideoSize(this.elements.remoteVideo, newResolution);
});
}
} else {
// 只有音频轨道(视频轨道尚未到达):不显示占位符,等待视频轨道到达
// 不设置 srcObject = null保持音频播放
console.log('Audio-only stream, waiting for video track...');
}
}
// 渲染本地用户状态
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');
}
}
}
// 渲染侧边栏用户列表支持多Participant动态渲染
renderUserList(localUser, remoteUser, participants) {
if (!this.elements.userList) return;
const participantsMap = participants || {};
const participantCount = Object.keys(participantsMap).length;
// 通话成员总数 = 本地用户(1) + participants中的条目数
// Host端participants只含其他participantParticipant端participants含host+其他participant
const userCount = 1 + participantCount;
// 更新通话成员总数显示
const userCountElement = this.elements.userCountDisplay;
if (userCountElement) {
userCountElement.textContent = `通话成员 (${userCount})`;
}
// 清空列表并重新渲染
this.elements.userList.innerHTML = '';
// 1. 渲染本地用户
// 判断当前用户角色Host端localUser是主持人Participant端localUser是参与者
this.elements.userList.appendChild(this.createUserEntry({
user: localUser,
role: 'local'
}));
// 2. 渲染远端成员
if (participantCount > 0) {
// 有participants数据Host端或Participant端收到participants-sync后
for (const [pid, p] of Object.entries(participantsMap)) {
if (p.role === 'host') {
this.elements.userList.appendChild(this.createUserEntry({
user: p,
role: 'host',
id: pid
}));
} else {
this.elements.userList.appendChild(this.createUserEntry({
user: p,
role: 'participant',
id: pid
}));
}
}
} else if (remoteUser.status !== 'offline') {
// 兼容Participant端未收到participants-sync时使用remoteUser显示Host
this.elements.userList.appendChild(this.createUserEntry({
user: remoteUser,
role: 'remote'
}));
}
}
// 创建通用用户条目
createUserEntry(options) {
const { user, role, id } = options;
const div = document.createElement('div');
const baseClass = 'flex items-center gap-3 p-2 rounded-lg';
div.className = role === 'local'
? `${baseClass} hover:bg-white/5`
: `${baseClass} bg-white/5`;
// dataset.userId
switch (role) {
case 'local':
div.dataset.userId = 'local';
break;
case 'remote':
div.dataset.userId = 'remote';
break;
case 'host':
div.dataset.userId = `host_${id}`;
break;
case 'participant':
div.dataset.userId = `participant_${id}`;
break;
}
const mediaState = user.mediaState;
const mediaStatusText = !mediaState.audio ? '静音中' : (!mediaState.video ? '视频关闭' : '在线');
const mediaStatusClass = (!mediaState.audio || !mediaState.video) ? 'text-xs text-gray-500' : 'text-xs text-green-400';
const muteIconHtml = !mediaState.audio
? '<i class="fas fa-microphone-slash text-gray-500 text-xs"></i>'
: '';
// 头像区域
let avatarHtml;
if (role === 'local') {
avatarHtml = `<img src="${user.avatar}" class="w-10 h-10 rounded-full object-cover">`;
} else {
avatarHtml = `
<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>
`;
}
// 角色标签
let roleTag;
if (role === 'local') {
const isHost = user.isHost;
roleTag = isHost
? '<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">主持人</span>'
: '<span class="text-xs bg-purple-500 px-1.5 rounded ml-1">参与者</span>';
} else if (role === 'participant') {
roleTag = '<span class="text-xs bg-purple-500 px-1.5 rounded ml-1">参与者</span>';
} else {
// remote, host
roleTag = '<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">主持人</span>';
}
// 媒体状态 data-field仅local
const dataFieldAttr = role === 'local' ? ' data-field="localUser.mediaStatus"' : '';
// 右侧内容
let rightHtml;
if (role === 'participant') {
const speakingHtml = (mediaState.isSpeaking && mediaState.audio)
? '<div class="audio-wave w-6"><span></span><span></span><span></span><span></span><span></span></div>'
: '';
rightHtml = `
<div class="flex items-center gap-2">
${muteIconHtml}
${speakingHtml}
</div>
`;
} else {
rightHtml = muteIconHtml;
}
div.innerHTML = `
${avatarHtml}
<div class="flex-1">
<div class="text-sm font-medium">
${user.name}
${roleTag}
</div>
<div class="${mediaStatusClass}"${dataFieldAttr}>${mediaStatusText}</div>
</div>
${rightHtml}
`;
return div;
}
// 在renderer.js中添加方法
// 获取视频流分辨率
getVideoResolution(track) {
if (track && track.getSettings) {
const settings = track.getSettings();
return {
width: settings.width || 640,
height: settings.height || 480
};
}
return { width: 640, height: 480 }; // 默认值
}
// 调整视频元素大小并居中显示
adjustVideoSize(videoElement, resolution) {
if (!videoElement) return;
const { width, height } = resolution;
const aspectRatio = width / height;
// 根据容器大小和视频宽高比调整视频显示
const container = videoElement.parentElement;
const containerWidth = container.clientWidth;
const containerHeight = container.clientHeight;
// 启用硬件加速
videoElement.style.transform = 'translateZ(0)';
videoElement.style.willChange = 'transform';
// 设置容器为flex布局使视频居中
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'; // 保持原始比例,不裁剪
}
// 渲染控制按钮
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;
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>
`;
}
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;
}
// 渲染未读消息数
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) {
// 始终显示网络状态
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');
// 根据网络质量设置图标和样式
switch (quality) {
case 'excellent':
icon.className = 'fas fa-check-circle text-green-400';
networkStatusText.textContent = this.getNetworkQualityText(quality);
networkStatusText.className = 'text-green-400';
break;
case 'good':
icon.className = 'fas fa-signal text-blue-400';
networkStatusText.textContent = this.getNetworkQualityText(quality);
networkStatusText.className = 'text-blue-400';
break;
case 'fair':
icon.className = 'fas fa-exclamation-circle text-yellow-500';
networkStatusText.textContent = this.getNetworkQualityText(quality);
networkStatusText.className = 'text-yellow-500';
break;
case 'poor':
icon.className = 'fas fa-exclamation-triangle text-red-500';
networkStatusText.textContent = this.getNetworkQualityText(quality);
networkStatusText.className = 'text-red-500';
break;
case 'no_signal':
icon.className = 'fas fa-times-circle text-gray-500';
networkStatusText.textContent = this.getNetworkQualityText(quality);
networkStatusText.className = 'text-gray-500';
break;
default:
icon.className = 'fas fa-question-circle text-gray-400';
networkStatusText.textContent = '未知';
networkStatusText.className = 'text-gray-400';
}
// 添加图标到网络状态元素
networkStatus.insertBefore(icon, networkStatusText);
}
if (this.elements.connectionQuality) {
const qualityText = this.getNetworkQualityText(quality);
let statusClass = '';
// 根据网络质量设置文本颜色
switch (quality) {
case 'excellent':
statusClass = 'text-green-400';
break;
case 'good':
statusClass = 'text-blue-400';
break;
case 'fair':
statusClass = 'text-yellow-500';
break;
case 'poor':
statusClass = 'text-red-500';
break;
case 'no_signal':
statusClass = 'text-gray-500';
break;
default:
statusClass = 'text-gray-400';
}
// 更新连接质量文本和样式
this.elements.connectionQuality.textContent = `连接质量: ${qualityText}`;
this.elements.connectionQuality.className = `text-xs ${statusClass}`;
}
// 同步更新头部网络指示器
this.updateHeaderNetworkIndicator(quality);
// 同步更新头部网络质量文本
this.renderHeaderNetworkStatus(quality);
}
// 更新头部网络指示器
updateHeaderNetworkIndicator(networkQuality) {
if (!this.elements.remoteNetworkIndicator) return;
// 根据网络质量设置指示器颜色
if (networkQuality === 'no_signal') {
// 无信号时显示灰色点,取消动画
this.elements.remoteNetworkIndicator.className = 'w-2 h-2 bg-gray-500 rounded-full';
} else {
// 有信号时显示绿色点,保持动画
this.elements.remoteNetworkIndicator.className = 'w-2 h-2 bg-green-500 rounded-full animate-pulse';
}
}
// 渲染通话结束
renderCallEnded() {
console.log('Call ended');
// 清理participant网格
const grid = this.elements.participantGrid;
if (grid) {
grid.querySelectorAll('[data-participant-id]').forEach(tile => {
const video = tile.querySelector('video');
if (video) video.srcObject = null;
tile.remove();
});
grid.classList.add('hidden');
}
// 跳转到结束通话界面
window.location.href = './endcall/endcall.html';
}
// 渲染participant离开host端房间仍然存在
renderParticipantLeft(connectionId) {
console.log(`Participant left: ${connectionId}, updating UI`);
const grid = this.elements.participantGrid;
if (grid) {
const tile = grid.querySelector(`[data-participant-id="${connectionId}"]`);
if (tile) {
const video = tile.querySelector('video');
if (video) video.srcObject = null;
tile.remove();
console.log(`Removed participant video tile for ${connectionId}`);
}
const remainingTiles = grid.querySelectorAll('[data-participant-id]');
if (remainingTiles.length === 0) {
grid.classList.add('hidden');
const remoteVideoDiv = this.elements.remoteVideo?.closest('.absolute.inset-0.video-fade-in');
if (remoteVideoDiv) {
remoteVideoDiv.classList.remove('hidden');
}
if (this.elements.remoteVideoPlaceholder) {
this.elements.remoteVideoPlaceholder.classList.remove('hidden');
}
} else {
grid.style.gridTemplateColumns = getGridTemplateColumns(remainingTiles.length);
}
}
if (this.elements.remoteNetworkIndicator) {
this.elements.remoteNetworkIndicator.className = 'w-2 h-2 bg-gray-500 rounded-full';
}
}
// 获取状态文本
getStatusText(status) {
const statusMap = {
'online': '在线',
'offline': '离线',
'connecting': '连接中'
};
return statusMap[status] || status;
}
// 获取网络质量文本
getNetworkQualityText(quality) {
const qualityMap = {
'excellent': '优秀',
'good': '良好',
'fair': '一般',
'poor': '较差',
'no_signal': '无信号'
};
return qualityMap[quality] || quality;
}
// 销毁
destroy() {
if (this.unsubscribe) {
this.unsubscribe();
}
if (this.messageUnsubscribe) {
this.messageUnsubscribe();
}
}
}
export default UIRenderer;