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