2026-03-03 17:51:30 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* UI渲染器
|
|
|
|
|
|
* 负责将状态映射到DOM,与状态管理解耦
|
|
|
|
|
|
*/
|
|
|
|
|
|
import { formatTime, formatTimestamp, toggleElement, toggleButtonState } from './utils.js';
|
2026-03-05 11:30:27 +08:00
|
|
|
|
import { mockCallSession } from './models.js';
|
2026-03-12 14:41:00 +08:00
|
|
|
|
import chatMessage from './chatmessage.js';
|
2026-04-11 19:05:19 +08:00
|
|
|
|
import store from './store.js';
|
2026-03-03 17:51:30 +08:00
|
|
|
|
class UIRenderer {
|
|
|
|
|
|
constructor(stateManager) {
|
|
|
|
|
|
this.stateManager = stateManager;
|
|
|
|
|
|
this.unsubscribe = stateManager.subscribe(this.render.bind(this));
|
|
|
|
|
|
|
|
|
|
|
|
// 缓存 DOM 元素
|
|
|
|
|
|
this.elements = {
|
2026-03-04 18:40:19 +08:00
|
|
|
|
// 头部和底部
|
|
|
|
|
|
header: document.querySelector('header'),
|
|
|
|
|
|
footer: document.querySelector('footer'),
|
2026-04-23 16:08:08 +08:00
|
|
|
|
// 多Participant视频网格
|
|
|
|
|
|
participantGrid: document.getElementById('participantGrid'),
|
2026-03-04 18:40:19 +08:00
|
|
|
|
// 头部内容
|
2026-03-03 17:51:30 +08:00
|
|
|
|
headerTitle: document.getElementById('headerTitle'),
|
|
|
|
|
|
callDuration: document.getElementById('callDuration'),
|
|
|
|
|
|
encryptionBadge: document.getElementById('encryptionBadge'),
|
|
|
|
|
|
unreadBadge: document.getElementById('unreadBadge'),
|
2026-03-12 17:53:34 +08:00
|
|
|
|
remoteNetworkIndicator: document.getElementById('remoteNetworkIndicator'),
|
|
|
|
|
|
remoteNetworkQuality: document.getElementById('remoteNetworkQuality'),
|
2026-03-03 17:51:30 +08:00
|
|
|
|
|
|
|
|
|
|
// 远端视频
|
|
|
|
|
|
remoteVideo: document.getElementById('remoteVideo'),
|
2026-03-05 11:30:27 +08:00
|
|
|
|
remoteVideoPlaceholder: document.getElementById('remoteVideoPlaceholder'),
|
2026-03-03 17:51:30 +08:00
|
|
|
|
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-04-10 21:54:33 +08:00
|
|
|
|
userCountDisplay: document.getElementById('userCountDisplay'),
|
2026-03-03 17:51:30 +08:00
|
|
|
|
// 控制按钮
|
|
|
|
|
|
micBtn: document.getElementById('micBtn'),
|
|
|
|
|
|
videoBtn: document.getElementById('videoBtn'),
|
|
|
|
|
|
recordBtn: document.getElementById('recordBtn'),
|
|
|
|
|
|
connectionQuality: document.getElementById('connectionQuality')
|
|
|
|
|
|
};
|
2026-03-04 18:40:19 +08:00
|
|
|
|
|
|
|
|
|
|
// 绑定事件监听器
|
|
|
|
|
|
this.bindEventListeners();
|
|
|
|
|
|
|
2026-03-04 17:55:55 +08:00
|
|
|
|
// 订阅状态变化
|
|
|
|
|
|
this.unsubscribe = stateManager.subscribe(this.render.bind(this));
|
2026-03-12 14:41:00 +08:00
|
|
|
|
// 订阅消息状态变化
|
|
|
|
|
|
this.messageUnsubscribe = chatMessage.subscribe(this.renderMessageState.bind(this));
|
2026-03-03 17:51:30 +08:00
|
|
|
|
// 初始化渲染
|
|
|
|
|
|
this.render(this.stateManager.getState(), { type: 'INIT' });
|
2026-03-05 11:30:27 +08:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-03-03 17:51:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 14:41:00 +08:00
|
|
|
|
// 渲染消息状态变化
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 18:40:19 +08:00
|
|
|
|
// 绑定事件监听器
|
|
|
|
|
|
bindEventListeners() {
|
|
|
|
|
|
// 事件监听器
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-11 19:05:19 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 渲染方法 - 根据状态变化更新UI
|
|
|
|
|
|
* @param {Object} state - 当前应用状态
|
|
|
|
|
|
* @param {Object} changes - 状态变化对象
|
|
|
|
|
|
*/
|
2026-03-03 17:51:30 +08:00
|
|
|
|
render(state, changes) {
|
2026-04-11 19:05:19 +08:00
|
|
|
|
// 根据变化类型执行不同的渲染操作
|
2026-03-03 17:51:30 +08:00
|
|
|
|
switch (changes.type) {
|
|
|
|
|
|
case 'INIT':
|
2026-04-11 19:05:19 +08:00
|
|
|
|
// 初始化渲染 - 渲染所有UI元素
|
|
|
|
|
|
this.renderRemoteVideo(state.session.remoteUser); // 渲染远程视频
|
|
|
|
|
|
this.renderLocalVideo(state.session.localUser, state.localStream); // 渲染本地视频
|
|
|
|
|
|
this.renderControlButtons(state.session.localUser.mediaState); // 渲染控制按钮
|
|
|
|
|
|
this.renderChatMessages(chatMessage.getMessageState().messages); // 渲染聊天消息
|
2026-04-25 16:53:50 +08:00
|
|
|
|
this.renderUserList(state.session.localUser, state.session.remoteUser, state.participants); // 渲染用户列表
|
2026-04-11 19:05:19 +08:00
|
|
|
|
this.renderHeader(state.session); // 渲染头部信息
|
2026-03-05 11:30:27 +08:00
|
|
|
|
// 初始化时检查远程流状态,显示或隐藏占位背景
|
|
|
|
|
|
if (this.elements.remoteVideoPlaceholder) {
|
|
|
|
|
|
if (state.remoteStream) {
|
2026-04-11 19:05:19 +08:00
|
|
|
|
this.elements.remoteVideoPlaceholder.classList.add('hidden'); // 有远程流时隐藏占位背景
|
2026-03-05 11:30:27 +08:00
|
|
|
|
} else {
|
2026-04-11 19:05:19 +08:00
|
|
|
|
this.elements.remoteVideoPlaceholder.classList.remove('hidden'); // 无远程流时显示占位背景
|
2026-03-05 11:30:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-03 17:51:30 +08:00
|
|
|
|
break;
|
|
|
|
|
|
case 'DURATION_UPDATE':
|
2026-04-11 19:05:19 +08:00
|
|
|
|
// 通话时长更新 - 渲染通话时长
|
2026-03-03 17:51:30 +08:00
|
|
|
|
this.renderCallDuration(changes.duration);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'LOCAL_MEDIA_CHANGE':
|
2026-04-11 19:05:19 +08:00
|
|
|
|
// 本地媒体状态变化 - 更新相关UI
|
|
|
|
|
|
this.renderControlButtons(state.session.localUser.mediaState); // 渲染控制按钮
|
|
|
|
|
|
this.renderLocalVideo(state.session.localUser, state.localStream); // 渲染本地视频
|
|
|
|
|
|
this.renderLocalUserStatus(state.session.localUser); // 渲染本地用户状态
|
2026-04-25 16:53:50 +08:00
|
|
|
|
this.renderUserList(state.session.localUser, state.session.remoteUser, state.participants); // 渲染用户列表
|
2026-03-03 17:51:30 +08:00
|
|
|
|
break;
|
2026-03-04 11:19:50 +08:00
|
|
|
|
case 'LOCAL_STREAM_OBTAINED':
|
2026-04-24 23:38:17 +08:00
|
|
|
|
this.renderLocalStream(state.localStream);
|
|
|
|
|
|
this.renderLocalVideo(state.session.localUser, state.localStream);
|
2026-03-04 11:19:50 +08:00
|
|
|
|
break;
|
2026-03-04 22:29:10 +08:00
|
|
|
|
case 'REMOTE_STREAM_OBTAINED':
|
2026-04-11 19:05:19 +08:00
|
|
|
|
// 远程流获取成功 - 更新远程视频显示
|
2026-04-23 16:08:08 +08:00
|
|
|
|
this.renderRemoteStream(changes.stream, changes.connectionId, changes.isHost); // 渲染远程流
|
2026-03-05 11:06:08 +08:00
|
|
|
|
// 当获取到远程流时,隐藏连接中提示
|
|
|
|
|
|
if (this.elements.connectingOverlay) {
|
|
|
|
|
|
this.elements.connectingOverlay.classList.add('hidden');
|
|
|
|
|
|
}
|
2026-03-04 22:29:10 +08:00
|
|
|
|
break;
|
2026-03-03 17:51:30 +08:00
|
|
|
|
case 'REMOTE_MEDIA_CHANGE':
|
2026-04-11 19:05:19 +08:00
|
|
|
|
// 远程媒体状态变化 - 更新远程视频和用户列表
|
|
|
|
|
|
this.renderRemoteVideo(state.session.remoteUser); // 渲染远程视频
|
2026-04-25 16:53:50 +08:00
|
|
|
|
this.renderUserList(state.session.localUser, state.session.remoteUser, state.participants); // 渲染用户列表
|
2026-04-25 13:29:35 +08:00
|
|
|
|
// Host端:精准更新发送者participant tile的占位背景
|
2026-04-25 18:34:54 +08:00
|
|
|
|
// 只有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);
|
2026-04-25 13:29:35 +08:00
|
|
|
|
}
|
2026-03-03 17:51:30 +08:00
|
|
|
|
break;
|
2026-03-12 15:33:34 +08:00
|
|
|
|
case 'USER_LIST_UPDATE':
|
2026-04-11 19:05:19 +08:00
|
|
|
|
// 用户列表更新 - 重新渲染用户列表
|
2026-04-25 16:53:50 +08:00
|
|
|
|
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);
|
2026-03-12 15:33:34 +08:00
|
|
|
|
break;
|
2026-03-03 17:51:30 +08:00
|
|
|
|
case 'NETWORK_CHANGE':
|
2026-04-11 19:05:19 +08:00
|
|
|
|
// 网络状态变化 - 渲染网络状态
|
2026-03-03 17:51:30 +08:00
|
|
|
|
this.renderNetworkStatus(changes.quality);
|
|
|
|
|
|
break;
|
2026-03-05 11:06:08 +08:00
|
|
|
|
case 'CALL_STATUS_CHANGE':
|
2026-04-11 19:05:19 +08:00
|
|
|
|
// 通话状态变化 - 渲染通话状态
|
2026-03-05 11:06:08 +08:00
|
|
|
|
this.renderCallStatus(changes.status);
|
|
|
|
|
|
break;
|
2026-03-03 17:51:30 +08:00
|
|
|
|
case 'CALL_ENDED':
|
2026-04-11 19:05:19 +08:00
|
|
|
|
// 通话结束 - 渲染通话结束界面
|
2026-03-03 17:51:30 +08:00
|
|
|
|
this.renderCallEnded();
|
|
|
|
|
|
break;
|
2026-04-23 15:22:24 +08:00
|
|
|
|
case 'PARTICIPANT_LEFT':
|
|
|
|
|
|
// participant离开 - 更新UI但房间仍然存在
|
|
|
|
|
|
this.renderParticipantLeft(changes.connectionId);
|
|
|
|
|
|
break;
|
2026-03-03 17:51:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 11:06:08 +08:00
|
|
|
|
// 渲染通话状态
|
|
|
|
|
|
renderCallStatus(status) {
|
|
|
|
|
|
if (this.elements.connectingOverlay) {
|
|
|
|
|
|
if (status === 'connecting') {
|
|
|
|
|
|
this.elements.connectingOverlay.classList.remove('hidden');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.elements.connectingOverlay.classList.add('hidden');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 17:51:30 +08:00
|
|
|
|
// 渲染头部
|
|
|
|
|
|
renderHeader(session) {
|
|
|
|
|
|
|
2026-04-11 19:05:19 +08:00
|
|
|
|
this.renderHeaderTitle();
|
2026-03-03 17:51:30 +08:00
|
|
|
|
if (this.elements.encryptionBadge) {
|
|
|
|
|
|
toggleElement(this.elements.encryptionBadge, session.isEncrypted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 21:54:33 +08:00
|
|
|
|
// 始终显示网络状态指示器和质量
|
|
|
|
|
|
if (this.elements.remoteNetworkIndicator) {
|
|
|
|
|
|
this.elements.remoteNetworkIndicator.classList.remove('hidden');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (this.elements.remoteNetworkQuality) {
|
|
|
|
|
|
this.elements.remoteNetworkQuality.classList.remove('hidden');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 17:51:30 +08:00
|
|
|
|
this.renderCallDuration(session.duration);
|
|
|
|
|
|
}
|
2026-04-11 19:05:19 +08:00
|
|
|
|
renderHeaderTitle() {
|
|
|
|
|
|
if (this.elements.headerTitle) {
|
|
|
|
|
|
const connectionId = store.getConnectionId() || '';
|
|
|
|
|
|
// 未连接时不显示红框部分
|
|
|
|
|
|
this.elements.headerTitle.textContent = `通话 (${connectionId})`;
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-03 17:51:30 +08:00
|
|
|
|
|
|
|
|
|
|
// 渲染通话时长
|
|
|
|
|
|
renderCallDuration(duration) {
|
|
|
|
|
|
if (this.elements.callDuration) {
|
|
|
|
|
|
this.elements.callDuration.textContent = formatTime(duration);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染远端视频
|
|
|
|
|
|
renderRemoteVideo(remoteUser) {
|
2026-04-11 00:03:30 +08:00
|
|
|
|
// 同步更新侧边栏用户列表
|
2026-04-25 16:53:50 +08:00
|
|
|
|
this.renderUserList(this.stateManager.getState().session.localUser, remoteUser, this.stateManager.getState().participants);
|
2026-04-11 00:03:30 +08:00
|
|
|
|
|
2026-03-12 15:48:20 +08:00
|
|
|
|
// 当远程视频关闭时显示占位符
|
|
|
|
|
|
if (this.elements.remoteVideoPlaceholder) {
|
|
|
|
|
|
const shouldShowPlaceholder = !remoteUser.mediaState.video;
|
|
|
|
|
|
toggleElement(this.elements.remoteVideoPlaceholder, shouldShowPlaceholder);
|
|
|
|
|
|
|
|
|
|
|
|
// 更新占位符文本内容
|
|
|
|
|
|
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 = '请确保对方已加入通话';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 17:51:30 +08:00
|
|
|
|
// 渲染网络状态
|
|
|
|
|
|
this.renderNetworkStatus(remoteUser.networkQuality);
|
2026-03-12 15:51:42 +08:00
|
|
|
|
|
|
|
|
|
|
// 渲染header中的网络状态
|
|
|
|
|
|
this.renderHeaderNetworkStatus(remoteUser.networkQuality);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染header中的网络状态
|
|
|
|
|
|
renderHeaderNetworkStatus(networkQuality) {
|
2026-03-12 17:53:34 +08:00
|
|
|
|
if (this.elements.remoteNetworkQuality) {
|
|
|
|
|
|
const textElement = this.elements.remoteNetworkQuality.querySelector('span');
|
|
|
|
|
|
const iconElement = this.elements.remoteNetworkQuality.querySelector('i');
|
2026-03-12 15:51:42 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-03 17:51:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染本地视频
|
2026-03-04 17:55:55 +08:00
|
|
|
|
renderLocalVideo(localUser, localStream) {
|
2026-03-03 17:51:30 +08:00
|
|
|
|
if (this.elements.localVideoPlaceholder) {
|
2026-03-04 17:55:55 +08:00
|
|
|
|
// 当没有视频流或视频关闭时显示占位符
|
|
|
|
|
|
const shouldShowPlaceholder = !localStream || !localUser.mediaState.video;
|
|
|
|
|
|
toggleElement(this.elements.localVideoPlaceholder, shouldShowPlaceholder);
|
2026-03-03 17:51:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-03-04 18:40:19 +08:00
|
|
|
|
|
|
|
|
|
|
// 隐藏断开连接覆盖层
|
|
|
|
|
|
if (this.elements.disconnectedOverlay) {
|
|
|
|
|
|
this.elements.disconnectedOverlay.classList.add('hidden');
|
|
|
|
|
|
}
|
2026-03-04 11:19:50 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
console.error('Either localVideo element or stream is missing');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 22:29:10 +08:00
|
|
|
|
// 渲染远程视频流
|
2026-04-23 16:08:08 +08:00
|
|
|
|
renderRemoteStream(stream, connectionId, isHost) {
|
|
|
|
|
|
if (isHost && connectionId) {
|
|
|
|
|
|
// Host端: 渲染到 participant 视频网格
|
|
|
|
|
|
this.renderParticipantStream(stream, connectionId);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Participant端: 渲染到单一远端视频(Host的画面)
|
|
|
|
|
|
this.renderSingleRemoteStream(stream);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染Host端的多Participant视频网格
|
2026-04-24 23:38:17 +08:00
|
|
|
|
// 每个participant tile显示该participant实际的远端视频流
|
2026-04-23 16:08:08 +08:00
|
|
|
|
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;
|
2026-04-24 23:38:17 +08:00
|
|
|
|
video.muted = false; // 不静音,播放participant的音频
|
2026-04-23 16:08:08 +08:00
|
|
|
|
video.id = `participantVideo_${connectionId}`;
|
|
|
|
|
|
tile.appendChild(video);
|
|
|
|
|
|
|
2026-04-25 13:06:37 +08:00
|
|
|
|
// 参与者视频关闭时的占位背景(复用 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);
|
|
|
|
|
|
|
2026-04-25 16:53:50 +08:00
|
|
|
|
// 参与者名称标签(优先使用participants中的真实姓名)
|
|
|
|
|
|
const pInfo = this.stateManager.getState().participants[connectionId];
|
|
|
|
|
|
const displayName = pInfo?.name || '参与者';
|
2026-04-23 16:08:08 +08:00
|
|
|
|
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';
|
2026-04-25 16:53:50 +08:00
|
|
|
|
label.innerHTML = `<i class="fas fa-user text-purple-400"></i><span>${displayName}</span>`;
|
2026-04-23 16:08:08 +08:00
|
|
|
|
tile.appendChild(label);
|
|
|
|
|
|
|
2026-04-24 23:38:17 +08:00
|
|
|
|
// 在线标识
|
2026-04-23 16:08:08 +08:00
|
|
|
|
const liveTag = document.createElement('div');
|
2026-04-24 23:38:17 +08:00
|
|
|
|
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>`;
|
2026-04-23 16:08:08 +08:00
|
|
|
|
tile.appendChild(liveTag);
|
|
|
|
|
|
|
|
|
|
|
|
grid.appendChild(tile);
|
|
|
|
|
|
console.log(`Created participant video tile for ${connectionId}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (tile) {
|
2026-04-24 23:38:17 +08:00
|
|
|
|
// 视频元素显示participant的远端视频流
|
2026-04-23 16:08:08 +08:00
|
|
|
|
const video = tile.querySelector('video');
|
2026-04-24 23:38:17 +08:00
|
|
|
|
if (video && stream) {
|
2026-04-25 19:26:39 +08:00
|
|
|
|
// 避免重复设置同一流对象(音频先到、视频后到时流对象相同)
|
|
|
|
|
|
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}`);
|
|
|
|
|
|
}
|
2026-04-23 16:08:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 隐藏单路远端视频和占位符
|
|
|
|
|
|
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;
|
|
|
|
|
|
if (tileCount <= 1) {
|
|
|
|
|
|
grid.style.gridTemplateColumns = '1fr';
|
|
|
|
|
|
} else if (tileCount <= 4) {
|
|
|
|
|
|
grid.style.gridTemplateColumns = 'repeat(2, 1fr)';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
grid.style.gridTemplateColumns = 'repeat(3, 1fr)';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 隐藏连接中提示
|
|
|
|
|
|
if (this.elements.connectingOverlay) {
|
|
|
|
|
|
this.elements.connectingOverlay.classList.add('hidden');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 隐藏远端视频占位符
|
|
|
|
|
|
if (this.elements.remoteVideoPlaceholder) {
|
|
|
|
|
|
this.elements.remoteVideoPlaceholder.classList.add('hidden');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-25 13:29:35 +08:00
|
|
|
|
// 精准更新指定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'}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-25 16:53:50 +08:00
|
|
|
|
// 同步更新所有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}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-23 16:08:08 +08:00
|
|
|
|
// 渲染Participant端的单一远端视频(Host画面)
|
|
|
|
|
|
renderSingleRemoteStream(stream) {
|
2026-04-25 19:26:39 +08:00
|
|
|
|
if (!this.elements.remoteVideo || !stream) {
|
|
|
|
|
|
console.error('Either remoteVideo element or stream is missing');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-06 15:15:01 +08:00
|
|
|
|
|
2026-04-25 19:26:39 +08:00
|
|
|
|
console.log('Rendering remote stream:', stream, 'tracks:', stream.getTracks().map(t => `${t.kind}(${t.readyState})`));
|
2026-03-06 15:15:01 +08:00
|
|
|
|
|
2026-04-25 19:26:39 +08:00
|
|
|
|
// 关键修复:避免 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;
|
|
|
|
|
|
}
|
2026-03-06 15:15:01 +08:00
|
|
|
|
|
2026-04-25 19:26:39 +08:00
|
|
|
|
// 首次设置或流对象变化:直接设置 srcObject(不使用 null 重置模式)
|
|
|
|
|
|
this.elements.remoteVideo.srcObject = stream;
|
|
|
|
|
|
this.elements.remoteVideo.autoplay = true;
|
|
|
|
|
|
this.elements.remoteVideo.playsinline = true;
|
|
|
|
|
|
this.elements.remoteVideo.muted = false;
|
2026-03-06 15:15:01 +08:00
|
|
|
|
|
2026-04-25 19:26:39 +08:00
|
|
|
|
// 确保视频开始播放
|
|
|
|
|
|
this.elements.remoteVideo.play().catch(e => {
|
|
|
|
|
|
console.log('Auto-play prevented, will retry on interaction:', e.message);
|
|
|
|
|
|
});
|
2026-03-12 17:53:34 +08:00
|
|
|
|
|
2026-04-25 19:26:39 +08:00
|
|
|
|
// 隐藏断开连接覆盖层
|
|
|
|
|
|
if (this.elements.disconnectedOverlay) {
|
|
|
|
|
|
this.elements.disconnectedOverlay.classList.add('hidden');
|
|
|
|
|
|
}
|
2026-03-06 15:15:01 +08:00
|
|
|
|
|
2026-04-25 19:26:39 +08:00
|
|
|
|
// 监听视频轨道变化
|
|
|
|
|
|
const videoTracks = stream.getVideoTracks();
|
|
|
|
|
|
const audioTracks = stream.getAudioTracks();
|
|
|
|
|
|
console.log(`Stream has ${videoTracks.length} video tracks, ${audioTracks.length} audio tracks`);
|
2026-03-05 11:30:27 +08:00
|
|
|
|
|
2026-04-25 19:26:39 +08:00
|
|
|
|
if (videoTracks.length > 0) {
|
|
|
|
|
|
// 有视频轨道:隐藏占位符
|
|
|
|
|
|
if (this.elements.remoteVideoPlaceholder) {
|
|
|
|
|
|
this.elements.remoteVideoPlaceholder.classList.add('hidden');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (this.elements.connectingOverlay) {
|
|
|
|
|
|
this.elements.connectingOverlay.classList.add('hidden');
|
2026-03-06 15:15:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-25 19:26:39 +08:00
|
|
|
|
// 监听视频轨道分辨率变化
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-03-05 11:30:27 +08:00
|
|
|
|
}
|
2026-04-25 19:26:39 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
// 只有音频轨道(视频轨道尚未到达):不显示占位符,等待视频轨道到达
|
|
|
|
|
|
// 不设置 srcObject = null,保持音频播放
|
|
|
|
|
|
console.log('Audio-only stream, waiting for video track...');
|
2026-03-04 22:29:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-25 16:53:50 +08:00
|
|
|
|
// 渲染侧边栏用户列表(支持多Participant动态渲染)
|
|
|
|
|
|
renderUserList(localUser, remoteUser, participants) {
|
2026-03-04 17:55:55 +08:00
|
|
|
|
if (!this.elements.userList) return;
|
|
|
|
|
|
|
2026-04-25 16:53:50 +08:00
|
|
|
|
const participantsMap = participants || {};
|
|
|
|
|
|
const participantCount = Object.keys(participantsMap).length;
|
2026-04-25 17:43:53 +08:00
|
|
|
|
|
|
|
|
|
|
// 通话成员总数 = 本地用户(1) + participants中的条目数
|
|
|
|
|
|
// Host端participants只含其他participant;Participant端participants含host+其他participant
|
|
|
|
|
|
const userCount = 1 + participantCount;
|
2026-04-10 21:54:33 +08:00
|
|
|
|
|
|
|
|
|
|
// 更新通话成员总数显示
|
2026-04-25 17:43:53 +08:00
|
|
|
|
const userCountElement = this.elements.userCountDisplay;
|
2026-04-11 00:03:30 +08:00
|
|
|
|
if (userCountElement) {
|
|
|
|
|
|
userCountElement.textContent = `通话成员 (${userCount})`;
|
|
|
|
|
|
}
|
2026-04-10 21:54:33 +08:00
|
|
|
|
|
2026-04-25 16:53:50 +08:00
|
|
|
|
// 清空列表并重新渲染
|
|
|
|
|
|
this.elements.userList.innerHTML = '';
|
2026-03-04 17:55:55 +08:00
|
|
|
|
|
2026-04-25 17:43:53 +08:00
|
|
|
|
// 1. 渲染本地用户
|
|
|
|
|
|
// 判断当前用户角色:Host端localUser是主持人;Participant端localUser是参与者
|
2026-04-25 16:53:50 +08:00
|
|
|
|
this.elements.userList.appendChild(this.createLocalUserEntry(localUser));
|
2026-04-10 21:54:33 +08:00
|
|
|
|
|
2026-04-25 17:43:53 +08:00
|
|
|
|
// 2. 渲染远端成员
|
|
|
|
|
|
if (participantCount > 0) {
|
|
|
|
|
|
// 有participants数据(Host端或Participant端收到participants-sync后)
|
2026-04-25 16:53:50 +08:00
|
|
|
|
for (const [pid, p] of Object.entries(participantsMap)) {
|
2026-04-25 17:43:53 +08:00
|
|
|
|
if (p.role === 'host') {
|
|
|
|
|
|
this.elements.userList.appendChild(this.createHostEntry(pid, p));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.elements.userList.appendChild(this.createParticipantEntry(pid, p));
|
|
|
|
|
|
}
|
2026-03-12 15:33:34 +08:00
|
|
|
|
}
|
2026-04-25 16:53:50 +08:00
|
|
|
|
} else if (remoteUser.status !== 'offline') {
|
2026-04-25 17:43:53 +08:00
|
|
|
|
// 兼容:Participant端未收到participants-sync时,使用remoteUser显示Host
|
2026-04-25 16:53:50 +08:00
|
|
|
|
this.elements.userList.appendChild(this.createRemoteUserEntry(remoteUser));
|
2026-03-04 17:55:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-25 16:53:50 +08:00
|
|
|
|
|
2026-04-25 17:43:53 +08:00
|
|
|
|
// 创建本地用户条目
|
2026-04-25 16:53:50 +08:00
|
|
|
|
createLocalUserEntry(localUser) {
|
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
|
div.className = 'flex items-center gap-3 p-2 rounded-lg hover:bg-white/5';
|
|
|
|
|
|
div.dataset.userId = 'local';
|
|
|
|
|
|
|
|
|
|
|
|
const mediaStatusText = !localUser.mediaState.audio ? '静音中' : (!localUser.mediaState.video ? '视频关闭' : '在线');
|
|
|
|
|
|
const mediaStatusClass = (!localUser.mediaState.audio || !localUser.mediaState.video) ? 'text-xs text-gray-500' : 'text-xs text-green-400';
|
|
|
|
|
|
const muteIconHtml = !localUser.mediaState.audio
|
|
|
|
|
|
? '<i class="fas fa-microphone-slash text-gray-500 text-xs"></i>'
|
|
|
|
|
|
: '';
|
|
|
|
|
|
|
2026-04-25 17:43:53 +08:00
|
|
|
|
// 根据是否为Host显示不同角色标签
|
|
|
|
|
|
const isHost = localUser.isHost;
|
|
|
|
|
|
const 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>';
|
|
|
|
|
|
|
2026-04-25 16:53:50 +08:00
|
|
|
|
div.innerHTML = `
|
|
|
|
|
|
<img src="${localUser.avatar}" class="w-10 h-10 rounded-full object-cover">
|
|
|
|
|
|
<div class="flex-1">
|
|
|
|
|
|
<div class="text-sm font-medium">
|
|
|
|
|
|
${localUser.name}
|
2026-04-25 17:43:53 +08:00
|
|
|
|
${roleTag}
|
2026-04-25 16:53:50 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="${mediaStatusClass}" data-field="localUser.mediaStatus">${mediaStatusText}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
${muteIconHtml}
|
|
|
|
|
|
`;
|
|
|
|
|
|
return div;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-25 17:43:53 +08:00
|
|
|
|
// 创建Host条目(Participant端显示Host用)
|
|
|
|
|
|
createHostEntry(hostId, hostInfo) {
|
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
|
div.className = 'flex items-center gap-3 p-2 rounded-lg bg-white/5';
|
|
|
|
|
|
div.dataset.userId = `host_${hostId}`;
|
|
|
|
|
|
|
|
|
|
|
|
const mediaStatusText = !hostInfo.mediaState.audio ? '静音中' : (!hostInfo.mediaState.video ? '视频关闭' : '在线');
|
|
|
|
|
|
const mediaStatusClass = (!hostInfo.mediaState.audio || !hostInfo.mediaState.video) ? 'text-xs text-gray-500' : 'text-xs text-green-400';
|
|
|
|
|
|
const muteIconHtml = !hostInfo.mediaState.audio
|
|
|
|
|
|
? '<i class="fas fa-microphone-slash text-gray-500 text-xs"></i>'
|
|
|
|
|
|
: '';
|
|
|
|
|
|
|
|
|
|
|
|
div.innerHTML = `
|
|
|
|
|
|
<div class="relative">
|
|
|
|
|
|
<img src="${hostInfo.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>
|
|
|
|
|
|
<div class="flex-1">
|
|
|
|
|
|
<div class="text-sm font-medium">
|
|
|
|
|
|
${hostInfo.name}
|
|
|
|
|
|
<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">主持人</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="${mediaStatusClass}">${mediaStatusText}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
${muteIconHtml}
|
|
|
|
|
|
`;
|
|
|
|
|
|
return div;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 创建远程用户条目(兼容回退:Participant端未收到participants-sync时显示Host)
|
2026-04-25 16:53:50 +08:00
|
|
|
|
createRemoteUserEntry(remoteUser) {
|
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
|
div.className = 'flex items-center gap-3 p-2 rounded-lg bg-white/5';
|
|
|
|
|
|
div.dataset.userId = 'remote';
|
|
|
|
|
|
|
|
|
|
|
|
const mediaStatusText = !remoteUser.mediaState.audio ? '静音中' : (!remoteUser.mediaState.video ? '视频关闭' : '在线');
|
|
|
|
|
|
const mediaStatusClass = (!remoteUser.mediaState.audio || !remoteUser.mediaState.video) ? 'text-xs text-gray-500' : 'text-xs text-green-400';
|
|
|
|
|
|
const muteIconHtml = !remoteUser.mediaState.audio
|
|
|
|
|
|
? '<i class="fas fa-microphone-slash text-gray-500 text-xs"></i>'
|
|
|
|
|
|
: '';
|
|
|
|
|
|
|
|
|
|
|
|
div.innerHTML = `
|
|
|
|
|
|
<div class="relative">
|
|
|
|
|
|
<img src="${remoteUser.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>
|
|
|
|
|
|
<div class="flex-1">
|
2026-04-25 17:43:53 +08:00
|
|
|
|
<div class="text-sm font-medium">
|
|
|
|
|
|
${remoteUser.name}
|
|
|
|
|
|
<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">主持人</span>
|
|
|
|
|
|
</div>
|
2026-04-25 16:53:50 +08:00
|
|
|
|
<div class="${mediaStatusClass}">${mediaStatusText}</div>
|
|
|
|
|
|
</div>
|
2026-04-25 17:43:53 +08:00
|
|
|
|
${muteIconHtml}
|
2026-04-25 16:53:50 +08:00
|
|
|
|
`;
|
|
|
|
|
|
return div;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 创建Participant条目(Host端显示每个Participant)
|
|
|
|
|
|
createParticipantEntry(participantId, participant) {
|
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
|
div.className = 'flex items-center gap-3 p-2 rounded-lg bg-white/5';
|
|
|
|
|
|
div.dataset.userId = `participant_${participantId}`;
|
|
|
|
|
|
|
|
|
|
|
|
const mediaStatusText = !participant.mediaState.audio ? '静音中' : (!participant.mediaState.video ? '视频关闭' : '在线');
|
|
|
|
|
|
const mediaStatusClass = (!participant.mediaState.audio || !participant.mediaState.video) ? 'text-xs text-gray-500' : 'text-xs text-green-400';
|
|
|
|
|
|
const muteIconHtml = !participant.mediaState.audio
|
|
|
|
|
|
? '<i class="fas fa-microphone-slash text-gray-500 text-xs"></i>'
|
|
|
|
|
|
: '';
|
|
|
|
|
|
const speakingHtml = (participant.mediaState.isSpeaking && participant.mediaState.audio)
|
|
|
|
|
|
? '<div class="audio-wave w-6"><span></span><span></span><span></span><span></span><span></span></div>'
|
|
|
|
|
|
: '';
|
|
|
|
|
|
|
|
|
|
|
|
div.innerHTML = `
|
|
|
|
|
|
<div class="relative">
|
|
|
|
|
|
<img src="${participant.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>
|
|
|
|
|
|
<div class="flex-1">
|
|
|
|
|
|
<div class="text-sm font-medium">
|
|
|
|
|
|
${participant.name}
|
|
|
|
|
|
<span class="text-xs bg-purple-500 px-1.5 rounded ml-1">参与者</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="${mediaStatusClass}">${mediaStatusText}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
|
${muteIconHtml}
|
|
|
|
|
|
${speakingHtml}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
return div;
|
|
|
|
|
|
}
|
2026-03-05 11:30:27 +08:00
|
|
|
|
// 在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 }; // 默认值
|
|
|
|
|
|
}
|
2026-03-04 17:55:55 +08:00
|
|
|
|
|
2026-03-05 11:30:27 +08:00
|
|
|
|
// 调整视频元素大小并居中显示
|
|
|
|
|
|
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 = 'pixelated';
|
|
|
|
|
|
// 确保视频元素在容器内正确显示
|
|
|
|
|
|
videoElement.style.maxWidth = '100%';
|
|
|
|
|
|
videoElement.style.maxHeight = '100%';
|
2026-03-12 15:38:40 +08:00
|
|
|
|
videoElement.style.objectFit = 'contain'; // 保持原始比例,不裁剪
|
2026-03-05 11:30:27 +08:00
|
|
|
|
}
|
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) {
|
2026-03-05 16:39:19 +08:00
|
|
|
|
// 始终显示网络状态
|
|
|
|
|
|
toggleElement(this.elements.networkStatus, true);
|
2026-03-03 17:51:30 +08:00
|
|
|
|
|
2026-03-05 16:39:19 +08:00
|
|
|
|
// 根据网络质量设置不同的图标和颜色
|
|
|
|
|
|
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;
|
2026-03-12 17:53:34 +08:00
|
|
|
|
case 'no_signal':
|
|
|
|
|
|
icon.className = 'fas fa-times-circle text-gray-500';
|
|
|
|
|
|
networkStatusText.textContent = this.getNetworkQualityText(quality);
|
|
|
|
|
|
networkStatusText.className = 'text-gray-500';
|
|
|
|
|
|
break;
|
2026-03-05 16:39:19 +08:00
|
|
|
|
default:
|
|
|
|
|
|
icon.className = 'fas fa-question-circle text-gray-400';
|
|
|
|
|
|
networkStatusText.textContent = '未知';
|
|
|
|
|
|
networkStatusText.className = 'text-gray-400';
|
2026-03-03 17:51:30 +08:00
|
|
|
|
}
|
2026-03-05 16:39:19 +08:00
|
|
|
|
|
|
|
|
|
|
// 添加图标到网络状态元素
|
|
|
|
|
|
networkStatus.insertBefore(icon, networkStatusText);
|
2026-03-03 17:51:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (this.elements.connectionQuality) {
|
2026-03-05 16:39:19 +08:00
|
|
|
|
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;
|
2026-03-12 17:53:34 +08:00
|
|
|
|
case 'no_signal':
|
|
|
|
|
|
statusClass = 'text-gray-500';
|
|
|
|
|
|
break;
|
2026-03-05 16:39:19 +08:00
|
|
|
|
default:
|
|
|
|
|
|
statusClass = 'text-gray-400';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新连接质量文本和样式
|
|
|
|
|
|
this.elements.connectionQuality.textContent = `连接质量: ${qualityText}`;
|
|
|
|
|
|
this.elements.connectionQuality.className = `text-xs ${statusClass}`;
|
2026-03-03 17:51:30 +08:00
|
|
|
|
}
|
2026-03-12 17:53:34 +08:00
|
|
|
|
|
|
|
|
|
|
// 同步更新头部网络指示器
|
|
|
|
|
|
this.updateHeaderNetworkIndicator(quality);
|
2026-04-10 21:54:33 +08:00
|
|
|
|
|
|
|
|
|
|
// 同步更新头部网络质量文本
|
|
|
|
|
|
this.renderHeaderNetworkStatus(quality);
|
2026-03-12 17:53:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新头部网络指示器
|
|
|
|
|
|
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';
|
|
|
|
|
|
}
|
2026-03-03 17:51:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 17:53:34 +08:00
|
|
|
|
|
2026-03-03 17:51:30 +08:00
|
|
|
|
// 渲染通话结束
|
|
|
|
|
|
renderCallEnded() {
|
|
|
|
|
|
console.log('Call ended');
|
2026-04-23 16:08:08 +08:00
|
|
|
|
|
|
|
|
|
|
// 清理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');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 18:40:19 +08:00
|
|
|
|
// 跳转到结束通话界面
|
|
|
|
|
|
window.location.href = './endcall/endcall.html';
|
2026-03-03 17:51:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-23 15:22:24 +08:00
|
|
|
|
// 渲染participant离开(host端,房间仍然存在)
|
|
|
|
|
|
renderParticipantLeft(connectionId) {
|
|
|
|
|
|
console.log(`Participant left: ${connectionId}, updating UI`);
|
2026-04-23 16:08:08 +08:00
|
|
|
|
|
|
|
|
|
|
const grid = this.elements.participantGrid;
|
|
|
|
|
|
if (grid) {
|
|
|
|
|
|
const tile = grid.querySelector(`[data-participant-id="${connectionId}"]`);
|
|
|
|
|
|
if (tile) {
|
|
|
|
|
|
const video = tile.querySelector('video');
|
2026-04-24 23:38:17 +08:00
|
|
|
|
if (video) video.srcObject = null;
|
2026-04-23 16:08:08 +08:00
|
|
|
|
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 {
|
|
|
|
|
|
if (remainingTiles.length <= 1) {
|
|
|
|
|
|
grid.style.gridTemplateColumns = '1fr';
|
|
|
|
|
|
} else if (remainingTiles.length <= 4) {
|
|
|
|
|
|
grid.style.gridTemplateColumns = 'repeat(2, 1fr)';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
grid.style.gridTemplateColumns = 'repeat(3, 1fr)';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-23 15:22:24 +08:00
|
|
|
|
if (this.elements.remoteNetworkIndicator) {
|
|
|
|
|
|
this.elements.remoteNetworkIndicator.className = 'w-2 h-2 bg-gray-500 rounded-full';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 17:51:30 +08:00
|
|
|
|
// 获取状态文本
|
|
|
|
|
|
getStatusText(status) {
|
|
|
|
|
|
const statusMap = {
|
|
|
|
|
|
'online': '在线',
|
|
|
|
|
|
'offline': '离线',
|
|
|
|
|
|
'connecting': '连接中'
|
|
|
|
|
|
};
|
|
|
|
|
|
return statusMap[status] || status;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取网络质量文本
|
|
|
|
|
|
getNetworkQualityText(quality) {
|
|
|
|
|
|
const qualityMap = {
|
|
|
|
|
|
'excellent': '优秀',
|
|
|
|
|
|
'good': '良好',
|
|
|
|
|
|
'fair': '一般',
|
2026-03-12 17:53:34 +08:00
|
|
|
|
'poor': '较差',
|
|
|
|
|
|
'no_signal': '无信号'
|
2026-03-03 17:51:30 +08:00
|
|
|
|
};
|
|
|
|
|
|
return qualityMap[quality] || quality;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 销毁
|
|
|
|
|
|
destroy() {
|
|
|
|
|
|
if (this.unsubscribe) {
|
|
|
|
|
|
this.unsubscribe();
|
|
|
|
|
|
}
|
2026-03-12 14:41:00 +08:00
|
|
|
|
if (this.messageUnsubscribe) {
|
|
|
|
|
|
this.messageUnsubscribe();
|
|
|
|
|
|
}
|
2026-03-03 17:51:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default UIRenderer;
|