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