Compare commits

4 Commits

Author SHA1 Message Date
83cf098c5f 本地音视频合并测试 2026-06-02 21:42:03 +08:00
d74a0c8121 优化 2026-05-25 22:58:11 +08:00
e6dfb28ef2 房间成员信息刷新 2026-05-25 22:21:50 +08:00
ad93ef342b 完成新页面开发 2026-05-25 21:57:58 +08:00
24 changed files with 2742 additions and 239 deletions

View File

@@ -1,8 +1,8 @@
/* eslint-disable no-undef */
import fetch from 'node-fetch';
import { TextEncoder, TextDecoder } from 'util';
import { PeerConnectionMock, SessionDescriptionMock, IceCandidateMock } from './test/peerconnectionmock';
import ResizeObserverMock from './test/resizeobservermock';
import { PeerConnectionMock, SessionDescriptionMock, IceCandidateMock } from './test/mocks/peerconnectionmock.js';
import ResizeObserverMock from './test/helpers/resizeobservermock.js';
// note: If set testEnvironment `jest-environment-jsdom`, below classes are not defined.
@@ -32,4 +32,4 @@ if (!window.RTCIceCandidate) {
if (!window.ResizeObserver) {
window.ResizeObserver = ResizeObserverMock;
}
}

View File

@@ -1,3 +1,5 @@
import { createTextElement, textValue } from '../../shared/dom.js';
export function createMessageElement(message, formatTimestamp) {
const messageDiv = document.createElement('div');
let messageClass = 'chat-bubble';
@@ -13,31 +15,45 @@ export function createMessageElement(message, formatTimestamp) {
messageDiv.className = messageClass;
messageDiv.dataset.messageId = message.id;
const contentHTML = message.type === 'file' && message.content.startsWith('data:image/')
? `
<div class="message-image-container">
<img src="${message.content}" class="message-image" alt="${message.fileName || '\u56fe\u7247'}">
${message.fileName ? `<div class="message-image-name">${message.fileName}</div>` : ''}
</div>
`
: `
<div class="message-text">
${message.content}
</div>
`;
const header = document.createElement('div');
header.className = 'message-header';
messageDiv.innerHTML = `
<div class="message-header">
<img src="${message.senderAvatar}" class="message-avatar">
<div>
<span class="message-sender">${message.senderName}</span>
<span class="message-time">${formatTimestamp(message.timestamp)}</span>
</div>
</div>
<div class="message-content">
${contentHTML}
</div>
`;
const avatar = document.createElement('img');
avatar.className = 'message-avatar';
avatar.src = textValue(message.senderAvatar);
avatar.alt = textValue(message.senderName, '\u7528\u6237');
header.appendChild(avatar);
const headerText = document.createElement('div');
headerText.appendChild(createTextElement('span', 'message-sender', message.senderName));
headerText.appendChild(createTextElement('span', 'message-time', formatTimestamp(message.timestamp)));
header.appendChild(headerText);
const content = document.createElement('div');
content.className = 'message-content';
const rawContent = textValue(message.content);
if (message.type === 'file' && rawContent.startsWith('data:image/')) {
const imageContainer = document.createElement('div');
imageContainer.className = 'message-image-container';
const image = document.createElement('img');
image.src = rawContent;
image.className = 'message-image';
image.alt = textValue(message.fileName, '\u56fe\u7247');
imageContainer.appendChild(image);
if (message.fileName) {
imageContainer.appendChild(createTextElement('div', 'message-image-name', message.fileName));
}
content.appendChild(imageContainer);
} else {
content.appendChild(createTextElement('div', 'message-text', rawContent));
}
messageDiv.appendChild(header);
messageDiv.appendChild(content);
return messageDiv;
}

View File

@@ -1,3 +1,5 @@
import { createIconElement, createTextElement, textValue } from '../../shared/dom.js';
function createParticipantPlaceholder() {
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';
@@ -15,32 +17,39 @@ function createParticipantPlaceholder() {
export function createParticipantTile(connectionId, displayName) {
const tile = document.createElement('div');
tile.className = 'relative bg-black/60 rounded-xl overflow-hidden flex items-center justify-center';
tile.dataset.participantId = connectionId;
tile.dataset.participantId = textValue(connectionId);
const video = document.createElement('video');
video.className = 'w-full h-full object-contain';
video.autoplay = true;
video.playsinline = true;
video.muted = false;
video.id = `participantVideo_${connectionId}`;
video.id = `participantVideo_${textValue(connectionId)}`;
tile.appendChild(video);
tile.appendChild(createParticipantPlaceholder());
const label = document.createElement('div');
label.className = 'absolute bottom-3 left-3 glass px-3 py-1 rounded-full text-xs flex items-center gap-2';
label.innerHTML = `<i class="fas fa-user text-purple-400"></i><span>${displayName || '\u53c2\u4e0e\u8005'}</span>`;
label.appendChild(createIconElement('fas fa-user text-purple-400'));
label.appendChild(createTextElement('span', '', displayName, '\u53c2\u4e0e\u8005'));
tile.appendChild(label);
const liveTag = document.createElement('div');
liveTag.className = 'absolute top-3 right-3 bg-green-500/80 px-2 py-0.5 rounded-full text-xs flex items-center gap-1';
liveTag.innerHTML = `<span class="w-1.5 h-1.5 bg-white rounded-full animate-pulse"></span><span>\u5728\u7ebf</span>`;
const pulse = document.createElement('span');
pulse.className = 'w-1.5 h-1.5 bg-white rounded-full animate-pulse';
liveTag.appendChild(pulse);
liveTag.appendChild(createTextElement('span', '', '\u5728\u7ebf'));
tile.appendChild(liveTag);
return tile;
}
export function getParticipantTile(grid, participantId) {
return grid?.querySelector(`[data-participant-id="${participantId}"]`) || null;
if (!grid) return null;
const expectedId = textValue(participantId);
return Array.from(grid.querySelectorAll('[data-participant-id]'))
.find(tile => tile.dataset.participantId === expectedId) || null;
}
export function updateParticipantTilePlaceholder(grid, participantId, showPlaceholder) {

View File

@@ -1,3 +1,5 @@
import { createIconElement, createTextElement, textValue } from '../../shared/dom.js';
const DEFAULT_NETWORK_QUALITY = {
label: '\u672a\u77e5',
statusIconClass: 'fas fa-question-circle text-gray-400',
@@ -50,18 +52,18 @@ const NETWORK_QUALITY_DISPLAY = {
}
};
function getRoleTagMarkup(user, role) {
function getRoleTagMeta(user, role) {
if (role === 'local') {
return user.isHost
? '<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">\u4e3b\u6301\u4eba</span>'
: '<span class="text-xs bg-purple-500 px-1.5 rounded ml-1">\u53c2\u4e0e\u8005</span>';
? { className: 'text-xs bg-indigo-500 px-1.5 rounded ml-1', label: '\u4e3b\u6301\u4eba' }
: { className: 'text-xs bg-purple-500 px-1.5 rounded ml-1', label: '\u53c2\u4e0e\u8005' };
}
if (role === 'participant') {
return '<span class="text-xs bg-purple-500 px-1.5 rounded ml-1">\u53c2\u4e0e\u8005</span>';
return { className: 'text-xs bg-purple-500 px-1.5 rounded ml-1', label: '\u53c2\u4e0e\u8005' };
}
return '<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">\u4e3b\u6301\u4eba</span>';
return { className: 'text-xs bg-indigo-500 px-1.5 rounded ml-1', label: '\u4e3b\u6301\u4eba' };
}
function getDatasetUserId(role, id) {
@@ -79,34 +81,55 @@ function getDatasetUserId(role, id) {
}
}
function getAvatarMarkup(user, role) {
if (role === 'local') {
return `<img src="${user.avatar}" class="w-10 h-10 rounded-full object-cover">`;
}
return `
<div class="relative">
<img src="${user.avatar}" class="w-10 h-10 rounded-full object-cover">
<div class="absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-slate-900"></div>
</div>
`;
function createAvatarImage(user) {
const image = document.createElement('img');
image.src = textValue(user.avatar);
image.alt = textValue(user.name, '\u7528\u6237');
image.className = 'w-10 h-10 rounded-full object-cover';
return image;
}
function getRightMarkup(mediaState, role, muteIconMarkup) {
if (role !== 'participant') {
return muteIconMarkup;
function createAvatarElement(user, role) {
if (role === 'local') {
return createAvatarImage(user);
}
const speakingMarkup = (mediaState.isSpeaking && mediaState.audio)
? '<div class="audio-wave w-6"><span></span><span></span><span></span><span></span><span></span></div>'
: '';
const wrapper = document.createElement('div');
wrapper.className = 'relative';
wrapper.appendChild(createAvatarImage(user));
return `
<div class="flex items-center gap-2">
${muteIconMarkup}
${speakingMarkup}
</div>
`;
const statusDot = document.createElement('div');
statusDot.className = 'absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-slate-900';
wrapper.appendChild(statusDot);
return wrapper;
}
function createAudioWaveElement() {
const wave = document.createElement('div');
wave.className = 'audio-wave w-6';
for (let i = 0; i < 5; i += 1) {
wave.appendChild(document.createElement('span'));
}
return wave;
}
function createRightElement(mediaState, role, muteIcon) {
if (role !== 'participant') {
return muteIcon;
}
const right = document.createElement('div');
right.className = 'flex items-center gap-2';
if (muteIcon) {
right.appendChild(muteIcon);
}
if (mediaState.isSpeaking && mediaState.audio) {
right.appendChild(createAudioWaveElement());
}
return right.childNodes.length > 0 ? right : null;
}
export function getCallTitle(connectionId) {
@@ -163,27 +186,40 @@ export function buildUserCountLabel(userCount) {
export function createUserEntryElement({ user, role, id }) {
const entry = document.createElement('div');
const mediaMeta = getMediaStatusMeta(user.mediaState);
const muteIconMarkup = mediaMeta.showMuteIcon
? `<i class="${mediaMeta.muteIconClass}"></i>`
const muteIcon = mediaMeta.showMuteIcon
? createIconElement(mediaMeta.muteIconClass)
: '';
const baseClass = 'flex items-center gap-3 p-2 rounded-lg';
const dataFieldAttr = role === 'local' ? ' data-field="localUser.mediaStatus"' : '';
entry.className = role === 'local'
? `${baseClass} hover:bg-white/5`
: `${baseClass} bg-white/5`;
entry.dataset.userId = getDatasetUserId(role, id);
entry.innerHTML = `
${getAvatarMarkup(user, role)}
<div class="flex-1">
<div class="text-sm font-medium">
${user.name}
${getRoleTagMarkup(user, role)}
</div>
<div class="${mediaMeta.className}"${dataFieldAttr}>${mediaMeta.text}</div>
</div>
${getRightMarkup(user.mediaState, role, muteIconMarkup)}
`;
entry.appendChild(createAvatarElement(user, role));
const details = document.createElement('div');
details.className = 'flex-1';
const nameRow = document.createElement('div');
nameRow.className = 'text-sm font-medium';
nameRow.appendChild(document.createTextNode(textValue(user.name)));
const roleTag = getRoleTagMeta(user, role);
nameRow.appendChild(createTextElement('span', roleTag.className, roleTag.label));
details.appendChild(nameRow);
const mediaStatus = createTextElement('div', mediaMeta.className, mediaMeta.text);
if (role === 'local') {
mediaStatus.dataset.field = 'localUser.mediaStatus';
}
details.appendChild(mediaStatus);
entry.appendChild(details);
const right = createRightElement(user.mediaState, role, muteIcon || null);
if (right) {
entry.appendChild(right);
}
return entry;
}

View File

@@ -1,3 +1,5 @@
import { createTextElement, textValue } from '../../shared/dom.js';
const EMPTY_CONNECTION_IDS_HTML = '<p class="text-gray-500 text-sm">\u6682\u65e0\u53ef\u7528\u7684\u8fde\u63a5ID</p>';
const EMPTY_USERS_HTML = '<p class="text-gray-500 text-sm">\u6682\u65e0\u5728\u7ebf\u7528\u6237</p>';
const HALL_LABEL = '\u5927\u5385\uff08\u672a\u52a0\u5165\u623f\u95f4\uff09';
@@ -10,13 +12,14 @@ const SELECT_LABEL = '\u9009\u62e9';
const USER_COUNT_SUFFIX = '\u4eba';
const ONLINE_USERS_SUMMARY_SUFFIX = ' \u4e2aWebSocket\u7528\u6237\u5728\u7ebf';
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
function getRoleTagClass(role) {
if (role === 'host') {
return 'text-xs px-2 py-1 rounded-full bg-indigo-500/20 text-indigo-300';
}
if (role === 'participant') {
return 'text-xs px-2 py-1 rounded-full bg-white/10 text-gray-300';
}
return 'text-xs px-2 py-1 rounded-full bg-emerald-500/20 text-emerald-300';
}
export async function fetchOnlineUsers() {
@@ -115,10 +118,8 @@ export function renderOnlineUsers({ users, currentUserId, onlineUsersList, users
const roomTitle = document.createElement('div');
roomTitle.className = 'flex items-center justify-between mb-2';
roomTitle.innerHTML = `
<span class="text-sm font-medium text-white">${escapeHtml(groupName)}</span>
<span class="text-xs text-gray-400">${roomUsers.length} ${USER_COUNT_SUFFIX}</span>
`;
roomTitle.appendChild(createTextElement('span', 'text-sm font-medium text-white', groupName));
roomTitle.appendChild(createTextElement('span', 'text-xs text-gray-400', `${roomUsers.length} ${USER_COUNT_SUFFIX}`));
section.appendChild(roomTitle);
const roomList = document.createElement('div');
@@ -135,19 +136,31 @@ export function renderOnlineUsers({ users, currentUserId, onlineUsersList, users
const userItem = document.createElement('div');
userItem.className = 'flex items-center justify-between rounded-lg bg-black/20 px-3 py-2';
userItem.innerHTML = `
<div class="flex items-center gap-3 min-w-0">
<img src="${escapeHtml(avatar)}" alt="${escapeHtml(userName)}" class="w-8 h-8 rounded-full object-cover">
<div class="min-w-0">
<div class="text-sm text-white truncate">${escapeHtml(userName)}</div>
<div class="text-xs text-gray-400 truncate">${escapeHtml(identity)}</div>
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-xs px-2 py-1 rounded-full ${user.role === 'host' ? 'bg-indigo-500/20 text-indigo-300' : (user.role === 'participant' ? 'bg-white/10 text-gray-300' : 'bg-emerald-500/20 text-emerald-300')}">${roleLabel}</span>
${isSelf ? `<span class="text-xs text-gray-500">${SELF_LABEL}</span>` : ''}
</div>
`;
const profile = document.createElement('div');
profile.className = 'flex items-center gap-3 min-w-0';
const avatarImage = document.createElement('img');
avatarImage.src = textValue(avatar);
avatarImage.alt = textValue(userName);
avatarImage.className = 'w-8 h-8 rounded-full object-cover';
profile.appendChild(avatarImage);
const info = document.createElement('div');
info.className = 'min-w-0';
info.appendChild(createTextElement('div', 'text-sm text-white truncate', userName));
info.appendChild(createTextElement('div', 'text-xs text-gray-400 truncate', identity));
profile.appendChild(info);
const status = document.createElement('div');
status.className = 'flex items-center gap-2';
status.appendChild(createTextElement('span', getRoleTagClass(user.role), roleLabel));
if (isSelf) {
status.appendChild(createTextElement('span', 'text-xs text-gray-500', SELF_LABEL));
}
userItem.appendChild(profile);
userItem.appendChild(status);
roomList.appendChild(userItem);
});

View File

@@ -91,7 +91,16 @@ export function buildSocketUserInfoPayload(userInfo, localUser) {
}
export function sendSocketUserInfo(signaling, payload) {
if (!signaling || typeof signaling.sendMessage !== 'function') {
if (!signaling) {
return;
}
if (typeof signaling.sendUserInfo === 'function') {
signaling.sendUserInfo(payload);
return;
}
if (typeof signaling.sendMessage !== 'function') {
return;
}

View File

@@ -164,9 +164,14 @@ class CallStateManager {
}
async uploadRecording({ blob, filename }) {
const formData = new FormData();
const people = this.buildRecordingPeopleMetadata();
formData.append('meetingId', this.connectionId || this.state.session.id || 'unknown');
formData.append('userId', this.state.session.localUser.id || '');
formData.append('filename', filename);
if (people.host) {
formData.append('host', JSON.stringify(people.host));
}
formData.append('participants', JSON.stringify(people.participants));
formData.append('recording', blob, filename);
const response = await fetch('/api/recordings', {
@@ -181,6 +186,46 @@ class CallStateManager {
return responseBody;
}
buildRecordingPeopleMetadata() {
const localUser = this.state.session.localUser || {};
const remoteUser = this.state.session.remoteUser || {};
const members = Object.entries(this.state.participants || {}).map(([participantId, participant]) => (
this._buildRecordingPerson(participant, participant.role || 'participant', participantId)
));
const remoteHost = members.find(member => member.role === 'host');
const localPerson = this._buildRecordingPerson(
localUser,
this.role === 'host' || localUser.isHost ? 'host' : 'participant',
this.selfParticipantId || (this.role === 'host' ? 'host' : 'local')
);
if (localPerson.role === 'host') {
return {
host: localPerson,
participants: members.filter(member => member.role !== 'host')
};
}
return {
host: remoteHost || this._buildRecordingPerson(remoteUser, 'host', 'host'),
participants: [
localPerson,
...members.filter(member => member.role !== 'host' && member.participantId !== localPerson.participantId)
]
};
}
_buildRecordingPerson(user = {}, role = 'participant', participantId = '') {
return {
participantId,
userId: user.id || user.userId || '',
id: user.id || user.userId || '',
name: user.name || '',
avatar: user.avatar || '',
role,
status: user.status || '',
mediaState: user.mediaState ? { ...user.mediaState } : undefined
};
}
async _updateLocalMediaRefactored(mediaType, value) {
if (mediaType === 'video' && value) {
await this._enableLocalVideo();

View File

@@ -56,11 +56,31 @@
<input id="searchInput" type="search" placeholder="搜索会议、文件或用户" autocomplete="off">
</div>
<select id="typeFilter" class="recordings-select">
<option value="all">全部格式</option>
<option value="mp4">MP4</option>
<option value="webm">WebM</option>
</select>
<div class="recordings-format-filter" id="typeFilterControl">
<select id="typeFilter" class="recordings-select-native" aria-hidden="true" tabindex="-1">
<option value="all">全部格式</option>
<option value="mp4">MP4</option>
<option value="webm">WebM</option>
</select>
<button id="typeFilterButton" class="recordings-filter-trigger" type="button" aria-haspopup="listbox" aria-expanded="false">
<span id="typeFilterText">全部格式</span>
<i class="fas fa-chevron-down"></i>
</button>
<div id="typeFilterMenu" class="recordings-filter-menu hidden" role="listbox" aria-label="录制格式筛选">
<button class="recordings-filter-option is-active" type="button" role="option" aria-selected="true" data-type-value="all">
<span>全部格式</span>
<small>所有录制</small>
</button>
<button class="recordings-filter-option" type="button" role="option" aria-selected="false" data-type-value="mp4">
<span>MP4</span>
<small>标准视频</small>
</button>
<button class="recordings-filter-option" type="button" role="option" aria-selected="false" data-type-value="webm">
<span>WebM</span>
<small>网页录制</small>
</button>
</div>
</div>
</section>
<section class="recordings-content">
@@ -127,6 +147,8 @@
<tr>
<th>文件</th>
<th>会议</th>
<th>房主</th>
<th>参与者</th>
<th>大小</th>
<th>上传时间</th>
<th>操作</th>
@@ -179,7 +201,7 @@
<input id="editOriginalFilename" class="recordings-input" type="text" required>
</div>
<div>
<label class="recordings-label" for="editUserId">用户 ID</label>
<label class="recordings-label" for="editUserId">房主用户 ID</label>
<input id="editUserId" class="recordings-input" type="text">
</div>
</div>

View File

@@ -9,6 +9,10 @@ const elements = {
refreshBtn: document.getElementById('refreshBtn'),
searchInput: document.getElementById('searchInput'),
typeFilter: document.getElementById('typeFilter'),
typeFilterControl: document.getElementById('typeFilterControl'),
typeFilterButton: document.getElementById('typeFilterButton'),
typeFilterText: document.getElementById('typeFilterText'),
typeFilterMenu: document.getElementById('typeFilterMenu'),
clearSearchBtn: document.getElementById('clearSearchBtn'),
uploadForm: document.getElementById('uploadForm'),
uploadBtn: document.getElementById('uploadBtn'),
@@ -41,6 +45,12 @@ const elements = {
notificationText: document.getElementById('notificationText')
};
const typeFilterLabels = {
all: '全部格式',
mp4: 'MP4',
webm: 'WebM'
};
function recordingKey(recording) {
return `${recording.meetingId}/${recording.filename}`;
}
@@ -89,6 +99,87 @@ function formatDate(value) {
});
}
function getPersonId(person) {
return person?.userId || person?.id || '';
}
function getPersonName(person) {
return person?.name || getPersonId(person) || '-';
}
function getRecordingHost(recording) {
return recording.host || (recording.userId ? {
userId: recording.userId,
id: recording.userId,
role: 'host'
} : null);
}
function getRecordingParticipants(recording) {
return Array.isArray(recording.participants) ? recording.participants : [];
}
function getPeopleSearchText(recording) {
const host = getRecordingHost(recording);
const participants = getRecordingParticipants(recording);
return [
host?.participantId,
host?.userId,
host?.id,
host?.name,
...participants.flatMap(participant => [
participant.participantId,
participant.userId,
participant.id,
participant.name
])
].filter(Boolean).join(' ');
}
function renderPersonSummary(person) {
if (!person) {
return '-';
}
const name = getPersonName(person);
const id = getPersonId(person);
return id && id !== name ? `${name} (${id})` : name;
}
function renderPeopleList(people) {
if (!people.length) {
return '<div class="recordings-person-empty">暂无参与者</div>';
}
return people.map((person) => `
<div class="recordings-person">
<img src="${escapeHtml(person.avatar || '/images/p2.png')}" alt="">
<div>
<strong>${escapeHtml(getPersonName(person))}</strong>
<span>${escapeHtml(getPersonId(person) || person.participantId || '-')}</span>
</div>
</div>
`).join('');
}
function setTypeFilter(value) {
const nextValue = typeFilterLabels[value] ? value : 'all';
elements.typeFilter.value = nextValue;
elements.typeFilterText.textContent = typeFilterLabels[nextValue];
elements.typeFilterMenu.querySelectorAll('[data-type-value]').forEach((option) => {
const isActive = option.dataset.typeValue === nextValue;
option.classList.toggle('is-active', isActive);
option.setAttribute('aria-selected', String(isActive));
});
}
function setTypeFilterMenuOpen(isOpen) {
elements.typeFilterControl.classList.toggle('is-open', isOpen);
elements.typeFilterMenu.classList.toggle('hidden', !isOpen);
elements.typeFilterButton.setAttribute('aria-expanded', String(isOpen));
}
function showNotification(message, isError = false) {
elements.notificationText.textContent = message;
elements.notification.classList.toggle('recordings-notification-error', isError);
@@ -143,7 +234,8 @@ function applyFilters() {
recording.meetingId,
recording.filename,
recording.originalFilename,
recording.userId
recording.userId,
getPeopleSearchText(recording)
].join(' ').toLowerCase();
return (type === 'all' || extension === type) && (!query || haystack.includes(query));
});
@@ -169,6 +261,8 @@ function renderTable() {
const key = recordingKey(recording);
const active = key === state.selectedKey ? 'recordings-row-active' : '';
const ext = escapeHtml((recording.filename || '').split('.').pop().toUpperCase());
const host = getRecordingHost(recording);
const participants = getRecordingParticipants(recording);
return `
<tr class="${active}" data-key="${escapeHtml(key)}">
@@ -182,6 +276,8 @@ function renderTable() {
</button>
</td>
<td>${escapeHtml(recording.meetingId)}</td>
<td>${escapeHtml(renderPersonSummary(host))}</td>
<td>${participants.length}</td>
<td>${formatBytes(recording.size)}</td>
<td>${formatDate(recording.uploadedAt)}</td>
<td>
@@ -222,6 +318,8 @@ function selectRecording(recording) {
}
state.selectedKey = recordingKey(recording);
const host = getRecordingHost(recording);
const participants = getRecordingParticipants(recording);
elements.previewVideo.src = recording.streamUrl;
elements.previewPlaceholder.classList.add('hidden');
elements.previewTitle.textContent = recording.originalFilename || recording.filename;
@@ -232,6 +330,14 @@ function selectRecording(recording) {
<div class="recordings-detail-row"><span>大小</span><strong>${formatBytes(recording.size)}</strong></div>
<div class="recordings-detail-row"><span>用户 ID</span><strong>${escapeHtml(recording.userId || '-')}</strong></div>
<div class="recordings-detail-row"><span>上传时间</span><strong>${formatDate(recording.uploadedAt)}</strong></div>
<div class="recordings-people-section">
<div class="recordings-people-title">房主</div>
${renderPeopleList(host ? [host] : [])}
</div>
<div class="recordings-people-section">
<div class="recordings-people-title">参与者 (${participants.length})</div>
${renderPeopleList(participants)}
</div>
<div class="recordings-preview-actions">
<a class="recordings-primary-btn" href="${escapeHtml(recording.downloadUrl)}">
<i class="fas fa-download"></i>
@@ -349,9 +455,32 @@ function bindEvents() {
elements.refreshBtn.addEventListener('click', loadRecordings);
elements.searchInput.addEventListener('input', applyFilters);
elements.typeFilter.addEventListener('change', applyFilters);
elements.typeFilterButton.addEventListener('click', () => {
setTypeFilterMenuOpen(!elements.typeFilterControl.classList.contains('is-open'));
});
elements.typeFilterMenu.addEventListener('click', (event) => {
const option = event.target.closest('[data-type-value]');
if (!option) {
return;
}
setTypeFilter(option.dataset.typeValue);
setTypeFilterMenuOpen(false);
applyFilters();
});
document.addEventListener('click', (event) => {
if (!elements.typeFilterControl.contains(event.target)) {
setTypeFilterMenuOpen(false);
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
setTypeFilterMenuOpen(false);
}
});
elements.clearSearchBtn.addEventListener('click', () => {
elements.searchInput.value = '';
elements.typeFilter.value = 'all';
setTypeFilter('all');
applyFilters();
});
elements.recordingFile.addEventListener('change', () => {
@@ -397,4 +526,5 @@ function bindEvents() {
}
bindEvents();
setTypeFilter(elements.typeFilter.value);
loadRecordings();

View File

@@ -0,0 +1,18 @@
export function textValue(value, fallback = '') {
return value == null || value === '' ? fallback : String(value);
}
export function createTextElement(tagName, className, value, fallback = '') {
const element = document.createElement(tagName);
if (className) {
element.className = className;
}
element.textContent = textValue(value, fallback);
return element;
}
export function createIconElement(className) {
const icon = document.createElement('i');
icon.className = className;
return icon;
}

View File

@@ -266,11 +266,13 @@ body {
}
.recordings-toolbar {
position: relative;
z-index: 50;
min-height: 76px;
border-radius: 16px;
padding: 14px;
display: grid;
grid-template-columns: repeat(3, minmax(112px, max-content)) minmax(220px, 1fr) 140px;
grid-template-columns: repeat(3, minmax(112px, max-content)) minmax(220px, 1fr) 152px;
align-items: center;
gap: 12px;
}
@@ -296,7 +298,6 @@ body {
}
.recordings-search,
.recordings-select,
.recordings-input {
height: 42px;
border-radius: 12px;
@@ -327,13 +328,118 @@ body {
color: #64748b;
}
.recordings-select {
padding: 0 12px;
outline: 0;
.recordings-format-filter {
position: relative;
z-index: 20;
min-width: 152px;
}
.recordings-select option {
color: #0f172a;
.recordings-select-native {
position: absolute;
inset: 0;
width: 100%;
height: 42px;
opacity: 0;
pointer-events: none;
}
.recordings-filter-trigger {
width: 100%;
height: 42px;
padding: 0 12px 0 14px;
border: 1px solid rgba(129, 140, 248, 0.45);
border-radius: 12px;
background: linear-gradient(180deg, rgba(30, 41, 59, 0.96), rgba(15, 23, 42, 0.94));
color: #e5e7eb;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 14px;
font-weight: 700;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 10px 24px rgba(2, 6, 23, 0.24);
}
.recordings-filter-trigger i {
color: #c7d2fe;
font-size: 12px;
transition: transform 0.2s ease;
}
.recordings-filter-trigger:hover,
.recordings-format-filter.is-open .recordings-filter-trigger {
border-color: rgba(165, 180, 252, 0.9);
background: linear-gradient(180deg, rgba(49, 46, 129, 0.72), rgba(30, 41, 59, 0.96));
}
.recordings-filter-trigger:focus-visible {
border-color: rgba(129, 140, 248, 0.95);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.recordings-format-filter.is-open .recordings-filter-trigger i {
transform: rotate(180deg);
}
.recordings-filter-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
left: 0;
z-index: 40;
padding: 6px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 14px;
background: rgba(15, 23, 42, 0.98);
box-shadow: 0 18px 40px rgba(2, 6, 23, 0.42), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.recordings-filter-option {
position: relative;
width: 100%;
min-height: 50px;
padding: 8px 34px 8px 10px;
border: 0;
border-radius: 10px;
background: transparent;
color: #e2e8f0;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 2px;
text-align: left;
cursor: pointer;
transition: background 0.18s, color 0.18s;
}
.recordings-filter-option span {
font-size: 14px;
font-weight: 700;
}
.recordings-filter-option small {
color: #64748b;
font-size: 11px;
line-height: 1;
}
.recordings-filter-option:hover,
.recordings-filter-option.is-active {
background: rgba(79, 70, 229, 0.22);
color: #ffffff;
}
.recordings-filter-option.is-active::after {
content: "\f00c";
position: absolute;
top: 50%;
right: 12px;
transform: translateY(-50%);
color: #a5b4fc;
font-family: "Font Awesome 6 Free";
font-size: 12px;
font-weight: 900;
}
.recordings-content {
@@ -415,7 +521,6 @@ body {
}
.recordings-input:focus,
.recordings-select:focus,
.recordings-search:focus-within {
border-color: rgba(129, 140, 248, 0.9);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.22);
@@ -534,11 +639,13 @@ body {
font-weight: 600;
}
.recordings-table th:nth-child(1) { width: 38%; }
.recordings-table th:nth-child(2) { width: 18%; }
.recordings-table th:nth-child(3) { width: 12%; }
.recordings-table th:nth-child(4) { width: 18%; }
.recordings-table th:nth-child(5) { width: 14%; }
.recordings-table th:nth-child(1) { width: 28%; }
.recordings-table th:nth-child(2) { width: 12%; }
.recordings-table th:nth-child(3) { width: 15%; }
.recordings-table th:nth-child(4) { width: 8%; }
.recordings-table th:nth-child(5) { width: 9%; }
.recordings-table th:nth-child(6) { width: 14%; }
.recordings-table th:nth-child(7) { width: 14%; }
.recordings-table tbody tr {
transition: background 0.2s;
@@ -670,6 +777,53 @@ body {
word-break: break-all;
}
.recordings-people-section {
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.recordings-people-title {
margin-bottom: 10px;
color: #94a3b8;
font-size: 13px;
font-weight: 600;
}
.recordings-person {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
}
.recordings-person img {
width: 34px;
height: 34px;
border-radius: 999px;
object-fit: cover;
background: rgba(255, 255, 255, 0.08);
}
.recordings-person strong,
.recordings-person span {
display: block;
max-width: 230px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recordings-person strong {
color: #ffffff;
font-size: 13px;
}
.recordings-person span,
.recordings-person-empty {
color: #64748b;
font-size: 12px;
}
.recordings-preview-actions {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -738,7 +892,7 @@ body {
}
.recordings-search,
.recordings-select {
.recordings-format-filter {
grid-column: 1 / -1;
}
@@ -755,6 +909,6 @@ body {
}
.recordings-table {
min-width: 760px;
min-width: 920px;
}
}

View File

@@ -2,10 +2,11 @@ import * as Logger from "../utils/logger.js";
export class Signaling extends EventTarget {
constructor(interval = 1000) {
constructor(interval = 1000, baseUrl = null) {
super();
this.running = false;
this.interval = interval;
this.baseUrl = baseUrl;
this.sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
}
@@ -19,7 +20,7 @@ export class Signaling extends EventTarget {
}
url(method, parameter = '') {
let ret = location.origin + '/signaling';
let ret = (this.baseUrl || location.origin) + '/signaling';
if (method)
ret += '/' + method;
if (parameter)
@@ -151,16 +152,17 @@ export class Signaling extends EventTarget {
export class WebSocketSignaling extends EventTarget {
constructor(interval = 1000) {
constructor(interval = 1000, websocketUrl = null) {
super();
this.interval = interval;
this.sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
let websocketUrl;
if (location.protocol === "https:") {
websocketUrl = "wss://" + location.host;
} else {
websocketUrl = "ws://" + location.host;
if (!websocketUrl) {
if (location.protocol === "https:") {
websocketUrl = "wss://" + location.host;
} else {
websocketUrl = "ws://" + location.host;
}
}
this.websocket = new WebSocket(websocketUrl);
@@ -301,6 +303,12 @@ export class WebSocketSignaling extends EventTarget {
this.websocket.send(sendJson);
}
sendUserInfo(payload) {
const sendJson = JSON.stringify({ type: 'host-userInfo', data: payload });
Logger.log(sendJson);
this.websocket.send(sendJson);
}
sendInviteCall(payload) {
const sendJson = JSON.stringify({ type: 'invite-call', data: payload });
Logger.log(sendJson);

View File

@@ -1,3 +1,4 @@
import { jest } from '@jest/globals';
import { MeetingRecorder } from '../../public/call/media/meeting-recorder.js';
class MediaStreamMock {
@@ -107,7 +108,7 @@ describe('MeetingRecorder', () => {
const result = await recorder.stop();
expect(result.filename).toContain('meeting-recording-123-456-789');
expect(result.mimeType).toBe('video/mp4;codecs=avc1.42E01E,mp4a.40.2');
expect(result.mimeType.toLowerCase()).toBe('video/mp4;codecs=avc1.42e01e,mp4a.40.2');
expect(result.filename).toMatch(/\.mp4$/);
expect(windowRef.URL.createObjectURL).not.toHaveBeenCalled();
expect(recorder.isRecording()).toBe(false);

View File

@@ -0,0 +1,88 @@
import { createMessageElement } from '../../public/call/chat/renderer-chat.js';
import { createParticipantTile, getParticipantTile } from '../../public/call/participants/renderer-participant-grid.js';
import { createUserEntryElement } from '../../public/call/renderers/renderer-ui.js';
import { renderOnlineUsers } from '../../public/call/signaling/connect-directory.js';
const formatTimestamp = value => value;
const unsafeText = '<img src=x onerror=alert(1)>Alice';
function mediaState(overrides = {}) {
return {
audio: true,
video: true,
isSpeaking: false,
...overrides
};
}
describe('safe dynamic rendering', () => {
afterEach(() => {
document.body.innerHTML = '';
});
test('renders chat text as text, not markup', () => {
const element = createMessageElement({
id: 'msg-1',
type: 'text',
isSelf: false,
senderName: unsafeText,
senderAvatar: '/images/p1.png',
content: unsafeText,
timestamp: 'now'
}, formatTimestamp);
expect(element.querySelector('.message-text').textContent).toBe(unsafeText);
expect(element.querySelector('.message-content img')).toBeNull();
expect(element.querySelector('.message-sender').textContent).toBe(unsafeText);
});
test('renders participant names safely and finds ids without selector injection', () => {
const participantId = 'room"] [data-bad="1';
const tile = createParticipantTile(participantId, unsafeText);
const grid = document.createElement('div');
grid.appendChild(tile);
expect(tile.querySelector('.absolute.bottom-3 span').textContent).toBe(unsafeText);
expect(tile.querySelector('.absolute.bottom-3 img')).toBeNull();
expect(getParticipantTile(grid, participantId)).toBe(tile);
});
test('renders user list entries without interpreting user profile fields as HTML', () => {
const entry = createUserEntryElement({
role: 'participant',
id: 'participant-1',
user: {
name: unsafeText,
avatar: '/images/p2.png',
mediaState: mediaState({ audio: false })
}
});
expect(entry.textContent).toContain(unsafeText);
expect(entry.querySelectorAll('img')).toHaveLength(1);
});
test('renders online users without injecting markup from directory data', () => {
const onlineUsersList = document.createElement('div');
const usersContainer = document.createElement('div');
const onlineUsersSummary = document.createElement('div');
renderOnlineUsers({
users: [{
name: unsafeText,
userId: unsafeText,
avatar: '/images/p1.png',
role: 'participant',
connectionId: 'room-1'
}],
currentUserId: 'other-user',
onlineUsersList,
usersContainer,
onlineUsersSummary
});
expect(usersContainer.textContent).toContain(unsafeText);
expect(usersContainer.querySelector('button')).toBeNull();
expect(usersContainer.querySelectorAll('img')).toHaveLength(1);
});
});

View File

@@ -1,18 +1,49 @@
import { jest } from '@jest/globals';
import fs from 'fs';
import * as Path from 'path';
import process from 'process';
import { setup, teardown } from 'jest-dev-server';
import { Signaling, WebSocketSignaling } from "../../src/core/signaling.js";
import { MockSignaling, reset } from "../mocks/mocksignaling.js";
import { waitFor, sleep, serverExeName } from "../helpers/testutils.js";
const portNumber = 8081;
const runSignalingIntegration = process.env.RUN_SIGNALING_INTEGRATION === '1';
const signalingModes = runSignalingIntegration
? [{ mode: "mock" }, { mode: "http" }, { mode: "websocket" }]
: [{ mode: "mock" }];
jest.setTimeout(10000);
describe.each([
{ mode: "mock" },
{ mode: "http" },
{ mode: "websocket" },
])('signaling test in public mode', ({ mode }) => {
function buildServerCommand(args = '') {
const binaryPath = Path.resolve(`../bin~/${serverExeName()}`);
const buildEntryPath = Path.resolve('../build/index.js');
const serverCommand = fs.existsSync(binaryPath)
? `"${binaryPath}"`
: `"${process.execPath}" "${buildEntryPath}"`;
return `${serverCommand} ${args}`.trim();
}
function getPortForMode(mode, isPrivate) {
if (mode === 'mock') {
return portNumber;
}
const publicPorts = { http: portNumber + 1, websocket: portNumber + 2 };
const privatePorts = { http: portNumber + 3, websocket: portNumber + 4 };
return (isPrivate ? privatePorts : publicPorts)[mode];
}
function createHttpSignaling(port) {
return new Signaling(1, `http://localhost:${port}`);
}
function createWebSocketSignaling(port) {
return new WebSocketSignaling(1, `ws://localhost:${port}`);
}
describe.each(signalingModes)('signaling test in public mode', ({ mode }) => {
let signaling1;
let signaling2;
const connectionId1 = "12345";
@@ -26,22 +57,22 @@ describe.each([
signaling1 = new MockSignaling(1);
signaling2 = new MockSignaling(1);
} else {
const path = Path.resolve(`../bin~/${serverExeName()}`);
let cmd = `${path} -p ${portNumber}`;
const serverPort = getPortForMode(mode, false);
let cmd = buildServerCommand(`-p ${serverPort}`);
if (mode == "http") {
cmd += " -t http";
}
await setup({ command: cmd, port: portNumber, usedPortAction: 'error' });
await setup({ command: cmd, port: serverPort, usedPortAction: 'error' });
if (mode == "http") {
signaling1 = new Signaling(1);
signaling2 = new Signaling(1);
signaling1 = createHttpSignaling(serverPort);
signaling2 = createHttpSignaling(serverPort);
}
if (mode == "websocket") {
signaling1 = new WebSocketSignaling(1);
signaling2 = new WebSocketSignaling(1);
signaling1 = createWebSocketSignaling(serverPort);
signaling2 = createWebSocketSignaling(serverPort);
}
}
@@ -50,8 +81,8 @@ describe.each([
});
afterAll(async () => {
await signaling1.stop();
await signaling2.stop();
await signaling1?.stop();
await signaling2?.stop();
signaling1 = null;
signaling2 = null;
@@ -207,11 +238,7 @@ describe.each([
});
});
describe.each([
{ mode: "mock" },
{ mode: "http" },
{ mode: "websocket" },
])('signaling test in private mode', ({ mode }) => {
describe.each(signalingModes)('signaling test in private mode', ({ mode }) => {
let signaling1;
let signaling2;
const connectionId = "12345";
@@ -226,22 +253,22 @@ describe.each([
return;
}
const path = Path.resolve(`../bin~/${serverExeName()}`);
let cmd = `${path} -p ${portNumber} -m private`;
const serverPort = getPortForMode(mode, true);
let cmd = buildServerCommand(`-p ${serverPort} -m private`);
if (mode == "http") {
cmd += " -t http";
}
await setup({ command: cmd, port: portNumber, usedPortAction: 'error' });
await setup({ command: cmd, port: serverPort, usedPortAction: 'error' });
if (mode == "http") {
signaling1 = new Signaling(1);
signaling2 = new Signaling(1);
signaling1 = createHttpSignaling(serverPort);
signaling2 = createHttpSignaling(serverPort);
}
if (mode == "websocket") {
signaling1 = new WebSocketSignaling(1);
signaling2 = new WebSocketSignaling(1);
signaling1 = createWebSocketSignaling(serverPort);
signaling2 = createWebSocketSignaling(serverPort);
}
await signaling1.start();
@@ -249,8 +276,8 @@ describe.each([
});
afterAll(async () => {
await signaling1.stop();
await signaling2.stop();
await signaling1?.stop();
await signaling2?.stop();
signaling1 = null;
signaling2 = null;

1335
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,7 @@
"swagger-jsdoc": "^6.2.1",
"swagger-ui-express": "^4.5.0",
"uuid": "^9.0.0",
"werift": "^0.23.0",
"ws": "^8.8.1"
},
"devDependencies": {

View File

@@ -7,7 +7,7 @@ import Offer from './offer';
import Answer from './answer';
import Candidate from './candidate';
import { v4 as uuid } from 'uuid';
import { onGetAllConnectionIds, onGetOnlineUsers as onGetWsOnlineUsers, onGetRooms as onGetWsRooms } from './websockethandler';
import { onGetAllConnectionIds, onGetOnlineUsers as onGetWsOnlineUsers, } from './websockethandler';
import { log, LogLevel } from '../log';
/**
* 断开连接记录类
@@ -996,57 +996,6 @@ function postCandidate(req: Request, res: Response): void {
arr.push(candidate);
res.sendStatus(200);
}
/**
* @swagger
* /signaling/rooms:
* get:
* summary: 获取房间和用户信息
* description: 获取所有房间的信息包括房间ID和链接的用户
* security:
* - sessionAuth: []
* responses:
* 200:
* description: 成功获取房间和用户信息
* content:
* application/json:
* schema:
* type: object
* properties:
* rooms:
* type: array
* items:
* type: object
* properties:
* roomId:
* type: string
* description: 房间ID
* users:
* type: array
* items:
* type: object
* properties:
* sessionId:
* type: string
* description: 会话ID
* connected:
* type: boolean
* description: 连接状态
* userCount:
* type: number
* description: 用户数量
* totalRooms:
* type: number
* description: 总房间数
*/
function onGetConnections(req: Request, res: Response): void {
const connectionId = typeof req.query.connectionId === 'string' ? req.query.connectionId : undefined;
const wsRooms = onGetWsRooms(connectionId).map((room) => ({
...room,
users: room.members
}));
res.json({ rooms: wsRooms, totalRooms: wsRooms.length });
}
/**
* @swagger
@@ -1160,7 +1109,6 @@ export {
postOffer, // 处理offer信令消息
postAnswer, // 处理answer信令消息
postCandidate, // 处理candidate信令消息
onGetConnections, // 获取房间和用户信息
getAllConnectionIds, // 获取所有连接ID
getOnlineUsers // 获取在线WebSocket用户列表
};

View File

@@ -0,0 +1,197 @@
import * as fs from 'fs';
import * as path from 'path';
import { v4 as uuid } from 'uuid';
import { RTCPeerConnection } from 'werift';
import { MediaRecorder } from 'werift/nonstandard';
import { log, LogLevel } from '../log';
type ServerAudioRecordingSession = {
recordingId: string;
meetingId: string;
peerConnection: RTCPeerConnection;
audioPath: string;
createdAt: number;
recorder?: MediaRecorder;
audioTrackCount: number;
localCandidates: any[];
};
type StartServerAudioRecordingOptions = {
meetingId?: string;
offerSdp: string;
iceServers?: any[];
};
type StartServerAudioRecordingResult = {
recordingId: string;
meetingId: string;
answerSdp: string;
candidates: any[];
audioPath: string;
};
type StoppedServerAudioRecording = {
recordingId: string;
meetingId: string;
audioPath: string;
hasAudio: boolean;
audioTrackCount: number;
createdAt: number;
stoppedAt: number;
};
function waitForIceGatheringComplete(peerConnection: RTCPeerConnection, timeoutMs: number): Promise<void> {
if (peerConnection.iceGatheringState === 'complete') {
return Promise.resolve();
}
return new Promise((resolve) => {
let done = false;
const subscription = peerConnection.iceGatheringStateChange.subscribe((state) => {
if (state === 'complete') {
finish();
}
});
const timer = setTimeout(finish, timeoutMs);
function finish(): void {
if (done) {
return;
}
done = true;
clearTimeout(timer);
subscription.unSubscribe();
resolve();
}
});
}
function toJsonCandidate(candidate: any): any {
if (!candidate) {
return candidate;
}
return typeof candidate.toJSON === 'function' ? candidate.toJSON() : candidate;
}
export class ServerAudioRecorderManager {
private sessions: Map<string, ServerAudioRecordingSession> = new Map<string, ServerAudioRecordingSession>();
constructor(private tempDir: string) {}
async start(options: StartServerAudioRecordingOptions): Promise<StartServerAudioRecordingResult> {
if (!options.offerSdp || typeof options.offerSdp !== 'string') {
throw new Error('offerSdp is required');
}
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true });
}
const recordingId = uuid();
const meetingId = options.meetingId || 'unknown';
const audioPath = path.join(this.tempDir, `${recordingId}.server-audio.webm`);
const peerConnection = new RTCPeerConnection({
iceServers: Array.isArray(options.iceServers) ? options.iceServers : []
});
const session: ServerAudioRecordingSession = {
recordingId,
meetingId,
peerConnection,
audioPath,
createdAt: Date.now(),
audioTrackCount: 0,
localCandidates: []
};
peerConnection.onIceCandidate.subscribe((candidate) => {
if (candidate) {
session.localCandidates.push(toJsonCandidate(candidate));
}
});
peerConnection.onTrack.subscribe((track) => {
if (track.kind !== 'audio') {
return;
}
session.audioTrackCount += 1;
if (session.recorder) {
log(LogLevel.warn, `Ignoring extra server audio track for recording ${recordingId}`);
return;
}
session.recorder = new MediaRecorder({
path: audioPath,
tracks: [track]
});
session.recorder.onError.subscribe((error) => {
log(LogLevel.error, `Server audio recorder error for ${recordingId}:`, error);
});
log(LogLevel.log, `Server audio track received for recording ${recordingId}`);
});
await peerConnection.setRemoteDescription({ type: 'offer', sdp: options.offerSdp });
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
await waitForIceGatheringComplete(peerConnection, 3000);
this.sessions.set(recordingId, session);
return {
recordingId,
meetingId,
answerSdp: (peerConnection.localDescription && peerConnection.localDescription.sdp) || answer.sdp,
candidates: session.localCandidates,
audioPath
};
}
async addCandidate(recordingId: string, candidate: any): Promise<boolean> {
const session = this.sessions.get(recordingId);
if (!session) {
return false;
}
await session.peerConnection.addIceCandidate(candidate || {});
return true;
}
async stop(recordingId: string): Promise<StoppedServerAudioRecording | null> {
const session = this.sessions.get(recordingId);
if (!session) {
return null;
}
this.sessions.delete(recordingId);
if (session.recorder) {
await session.recorder.stop();
}
await session.peerConnection.close();
const hasAudio = fs.existsSync(session.audioPath) && fs.statSync(session.audioPath).size > 0;
return {
recordingId: session.recordingId,
meetingId: session.meetingId,
audioPath: session.audioPath,
hasAudio,
audioTrackCount: session.audioTrackCount,
createdAt: session.createdAt,
stoppedAt: Date.now()
};
}
async cancel(recordingId: string): Promise<boolean> {
const stopped = await this.stop(recordingId);
if (!stopped) {
return false;
}
if (fs.existsSync(stopped.audioPath)) {
fs.unlinkSync(stopped.audioPath);
}
return true;
}
}

View File

@@ -0,0 +1,82 @@
# Server Audio Recording API
This API lets Unity keep local video recording while the server records app audio as an extra WebRTC peer. When Unity stops local recording, upload the local video to the stop endpoint and the server merges it with the recorded audio.
## 1. Start
`POST /api/server-audio-recordings/start`
Body:
```json
{
"meetingId": "room-001",
"offerSdp": "v=0...",
"iceServers": []
}
```
Response:
```json
{
"success": true,
"recordingId": "uuid",
"meetingId": "room-001",
"answerSdp": "v=0...",
"candidates": []
}
```
Unity should create a peer connection with an audio track only, send its offer SDP here, then set the returned answer SDP as the remote description.
## 2. Trickle ICE
`POST /api/server-audio-recordings/{recordingId}/candidate`
Body:
```json
{
"candidate": "candidate:...",
"sdpMid": "0",
"sdpMLineIndex": 0
}
```
## 3. Stop And Merge
`POST /api/server-audio-recordings/{recordingId}/stop`
Content type: `multipart/form-data`
Fields:
- `video`: the local video file recorded by Unity.
- `meetingId`: optional, overrides the start meeting id.
- `filename`: optional display filename.
- `userId`: optional host user id.
- `host`: optional JSON host metadata.
- `participants`: optional JSON participant metadata array.
Response:
```json
{
"success": true,
"recordingId": "uuid",
"meetingId": "room-001",
"filename": "2026-06-02T13-00-00-000Z-uuid.mp4",
"merged": true,
"url": "/api/recordings/room-001/2026-06-02T13-00-00-000Z-uuid.mp4/download"
}
```
The server keeps the local video track, replaces audio with the server-recorded app audio, and stores the merged file in the existing `recordings` directory.
## 4. Cancel
`DELETE /api/server-audio-recordings/{recordingId}`
Use this if local recording is aborted and no merged output should be saved.

View File

@@ -2,12 +2,14 @@ import * as express from 'express';
import * as path from 'path';
import * as fs from 'fs';
import * as morgan from 'morgan';
import { spawn } from 'child_process';
import { v4 as uuid } from 'uuid';
import signaling from './signaling';
import { log, LogLevel } from './log';
import Options from './class/options';
import { reset as resetHandler } from './class/httphandler';
import { initSwagger } from './swagger';
import { ServerAudioRecorderManager } from './class/serveraudiorecorder';
const cors = require('cors');
const multer = require('multer');
@@ -27,10 +29,23 @@ type RecordingMetadata = {
mimetype?: string;
size?: number;
userId?: string;
host?: RecordingPerson;
participants?: RecordingPerson[];
uploadedAt?: string;
updatedAt?: string;
};
type RecordingPerson = {
participantId?: string;
userId?: string;
id?: string;
name?: string;
avatar?: string;
role?: string;
status?: string;
mediaState?: any;
};
function safeAvatarExtension(file: any): string {
const originalExt = path.extname(file.originalname || '').toLowerCase();
if (ALLOWED_AVATAR_EXTENSIONS.has(originalExt)) {
@@ -101,6 +116,96 @@ function isAllowedRecording(file: any): boolean {
return ext.length > 0 && isCompatibleMime;
}
function getRecordingMimeTypeFromExtension(ext: string): string {
return ext.toLowerCase() === '.mp4' ? 'video/mp4' : 'video/webm';
}
function parseJsonField(value: any): any {
if (value === undefined || value === null || value === '') {
return undefined;
}
if (typeof value !== 'string') {
return value;
}
try {
return JSON.parse(value);
} catch (_error) {
return undefined;
}
}
function sanitizeMetadataString(value: any, maxLength = 200): string {
if (value === undefined || value === null) {
return '';
}
return String(value)
.split('')
.filter((char) => {
const code = char.charCodeAt(0);
return code > 31 && code !== 127;
})
.join('')
.trim()
.slice(0, maxLength);
}
function sanitizeRecordingPerson(value: any, fallbackRole: string): RecordingPerson | undefined {
const parsed = parseJsonField(value);
if (!parsed || typeof parsed !== 'object') {
return undefined;
}
const person: RecordingPerson = {
participantId: sanitizeMetadataString(parsed.participantId || parsed.connectionId, 120),
userId: sanitizeMetadataString(parsed.userId || parsed.id, 120),
id: sanitizeMetadataString(parsed.id || parsed.userId, 120),
name: sanitizeMetadataString(parsed.name, 120),
avatar: sanitizeMetadataString(parsed.avatar, 400),
role: sanitizeMetadataString(parsed.role || fallbackRole, 40),
status: sanitizeMetadataString(parsed.status, 40)
};
if (parsed.mediaState && typeof parsed.mediaState === 'object') {
person.mediaState = {
audio: Boolean(parsed.mediaState.audio),
video: Boolean(parsed.mediaState.video),
screenShare: Boolean(parsed.mediaState.screenShare),
recording: Boolean(parsed.mediaState.recording),
isSpeaking: Boolean(parsed.mediaState.isSpeaking)
};
}
return person;
}
function sanitizeRecordingParticipants(value: any): RecordingPerson[] {
const parsed = parseJsonField(value);
if (!Array.isArray(parsed)) {
return [];
}
return parsed
.slice(0, 100)
.map((participant) => sanitizeRecordingPerson(participant, 'participant'))
.filter((participant) => Boolean(participant));
}
function buildFallbackRecordingHost(userId: string | undefined): RecordingPerson | undefined {
const safeUserId = sanitizeMetadataString(userId, 120);
if (!safeUserId) {
return undefined;
}
return {
userId: safeUserId,
id: safeUserId,
role: 'host'
};
}
function readRecordingMetadata(metadataPath: string): RecordingMetadata {
try {
if (!fs.existsSync(metadataPath)) {
@@ -115,11 +220,13 @@ function readRecordingMetadata(metadataPath: string): RecordingMetadata {
}
function getRecordingMimeType(filename: string, metadata: RecordingMetadata): string {
if (metadata.mimetype) {
return metadata.mimetype;
const ext = path.extname(filename);
const mimetype = normalizeMimeType(metadata.mimetype);
if (mimetype && mimetype !== 'text/plain' && mimetype !== 'application/octet-stream') {
return mimetype;
}
return path.extname(filename).toLowerCase() === '.mp4' ? 'video/mp4' : 'video/webm';
return getRecordingMimeTypeFromExtension(ext);
}
function buildRecordingInfo(recordingRoot: string, meetingId: string, filename: string) {
@@ -129,6 +236,7 @@ function buildRecordingInfo(recordingRoot: string, meetingId: string, filename:
const metadata = readRecordingMetadata(metadataPath);
const resolvedMeetingId = metadata.meetingId || meetingId;
const resolvedFilename = metadata.filename || filename;
const participants = Array.isArray(metadata.participants) ? metadata.participants : [];
return {
id: metadata.id || path.basename(filename, path.extname(filename)),
@@ -138,6 +246,9 @@ function buildRecordingInfo(recordingRoot: string, meetingId: string, filename:
mimetype: getRecordingMimeType(filename, metadata),
size: stat.size,
userId: metadata.userId || '',
host: metadata.host || buildFallbackRecordingHost(metadata.userId),
participants,
participantCount: participants.length,
uploadedAt: metadata.uploadedAt || stat.birthtime.toISOString(),
updatedAt: metadata.updatedAt || stat.mtime.toISOString(),
modifiedAt: stat.mtime.toISOString(),
@@ -184,6 +295,75 @@ function removeEmptyDirectory(directory: string): void {
}
}
function removeFileIfExists(filePath: string | undefined): void {
if (!filePath) {
return;
}
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch (error) {
log(LogLevel.warn, 'Failed to remove temporary file:', error);
}
}
function getMergedRecordingExtension(videoExt: string): string {
return videoExt.toLowerCase() === '.webm' ? '.webm' : '.mp4';
}
function runFfmpeg(args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const ffmpegPath = process.env.FFMPEG_PATH || 'ffmpeg';
const child = spawn(ffmpegPath, args, { windowsHide: true });
let stderr = '';
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`ffmpeg exited with code ${code}: ${stderr.slice(-2000)}`));
});
});
}
function mergeVideoWithServerAudio(videoPath: string, audioPath: string, outputPath: string, outputExt: string): Promise<void> {
const isWebmOutput = outputExt.toLowerCase() === '.webm';
const args = isWebmOutput
? [
'-y',
'-i', videoPath,
'-i', audioPath,
'-map', '0:v:0',
'-map', '1:a:0',
'-c:v', 'copy',
'-c:a', 'libopus',
'-shortest',
outputPath
]
: [
'-y',
'-i', videoPath,
'-i', audioPath,
'-map', '0:v:0',
'-map', '1:a:0',
'-c:v', 'copy',
'-c:a', 'aac',
'-shortest',
'-movflags', '+faststart',
outputPath
];
return runFfmpeg(args);
}
export const createServer = (config: Options): express.Express => {
const app: express.Express = express();
resetHandler(config.mode);
@@ -318,6 +498,7 @@ export const createServer = (config: Options): express.Express => {
const recordingRoot = getRecordingRoot();
const recordingTempDir = path.join(recordingRoot, '.tmp');
const serverAudioRecordings = new ServerAudioRecorderManager(recordingTempDir);
const recordingStorage = multer.diskStorage({
destination: (_req: any, _file: any, cb: (error: Error | null, destination: string) => void) => {
if (!fs.existsSync(recordingTempDir)) {
@@ -351,6 +532,194 @@ export const createServer = (config: Options): express.Express => {
}
});
app.post('/api/server-audio-recordings/start', async (req: express.Request, res: express.Response) => {
try {
const offerSdp = req.body.offerSdp || req.body.sdp;
const meetingId = sanitizePathSegment(req.body.meetingId, 'unknown');
const started = await serverAudioRecordings.start({
meetingId,
offerSdp,
iceServers: Array.isArray(req.body.iceServers) ? req.body.iceServers : undefined
});
res.json({
success: true,
recordingId: started.recordingId,
meetingId: started.meetingId,
answerSdp: started.answerSdp,
candidates: started.candidates
});
} catch (error) {
log(LogLevel.error, 'Failed to start server audio recording:', error);
res.status(400).json({
success: false,
message: error instanceof Error ? error.message : 'Failed to start server audio recording'
});
}
});
app.post('/api/server-audio-recordings/:recordingId/candidate', async (req: express.Request, res: express.Response) => {
try {
const recordingId = sanitizePathSegment(req.params.recordingId, '');
const candidate = req.body.candidate && typeof req.body.candidate === 'object'
? req.body.candidate
: {
candidate: req.body.candidate,
sdpMid: req.body.sdpMid,
sdpMLineIndex: req.body.sdpMLineIndex
};
const added = await serverAudioRecordings.addCandidate(recordingId, candidate);
if (!added) {
res.status(404).json({ success: false, message: 'Server audio recording not found' });
return;
}
res.json({ success: true });
} catch (error) {
log(LogLevel.error, 'Failed to add server audio ICE candidate:', error);
res.status(400).json({
success: false,
message: error instanceof Error ? error.message : 'Failed to add server audio ICE candidate'
});
}
});
app.delete('/api/server-audio-recordings/:recordingId', async (req: express.Request, res: express.Response) => {
const recordingId = sanitizePathSegment(req.params.recordingId, '');
const cancelled = await serverAudioRecordings.cancel(recordingId);
if (!cancelled) {
res.status(404).json({ success: false, message: 'Server audio recording not found' });
return;
}
res.json({ success: true });
});
app.post('/api/server-audio-recordings/:recordingId/stop', (req: express.Request, res: express.Response) => {
const recordingId = sanitizePathSegment(req.params.recordingId, '');
const stopPromise = serverAudioRecordings.stop(recordingId);
stopPromise.catch(() => undefined);
recordingUpload.single('video')(req, res, async (error: Error) => {
const request = req as any;
let stopped = null;
try {
stopped = await stopPromise;
if (!stopped) {
removeFileIfExists(request.file && request.file.path);
res.status(404).json({ success: false, message: 'Server audio recording not found' });
return;
}
if (error) {
removeFileIfExists(stopped.audioPath);
log(LogLevel.warn, 'Server audio merge upload rejected:', error.message);
const isSizeLimit = error.name === 'MulterError' && (error as any).code === 'LIMIT_FILE_SIZE';
res.status(400).json({
success: false,
message: isSizeLimit ? 'Recording file is too large' : error.message
});
return;
}
if (!stopped.hasAudio) {
removeFileIfExists(request.file && request.file.path);
removeFileIfExists(stopped.audioPath);
res.status(400).json({ success: false, message: 'No server audio was captured' });
return;
}
if (!request.file) {
res.json({
success: true,
recordingId: stopped.recordingId,
meetingId: stopped.meetingId,
audioOnly: true,
audioTrackCount: stopped.audioTrackCount
});
return;
}
const videoExt = safeRecordingExtension(request.file);
if (!videoExt) {
removeFileIfExists(request.file.path);
removeFileIfExists(stopped.audioPath);
res.status(400).json({ success: false, message: 'Unsupported recording file type' });
return;
}
const finalExt = getMergedRecordingExtension(videoExt);
const meetingId = sanitizePathSegment(request.body.meetingId || stopped.meetingId, 'unknown');
const originalFilename = path.basename(request.body.filename || request.file.originalname || `recording${finalExt}`);
const userId = sanitizeMetadataString(request.body.userId, 120);
const host = sanitizeRecordingPerson(request.body.host, 'host') || buildFallbackRecordingHost(userId);
const participants = sanitizeRecordingParticipants(request.body.participants);
const finalFilename = `${new Date().toISOString().replace(/[:.]/g, '-')}-${stopped.recordingId}${finalExt}`;
const meetingDir = path.join(recordingRoot, meetingId);
const finalPath = path.join(meetingDir, finalFilename);
if (!isPathInside(recordingRoot, finalPath)) {
removeFileIfExists(request.file.path);
removeFileIfExists(stopped.audioPath);
res.status(400).json({ success: false, message: 'Invalid recording path' });
return;
}
if (!fs.existsSync(meetingDir)) {
fs.mkdirSync(meetingDir, { recursive: true });
}
await mergeVideoWithServerAudio(request.file.path, stopped.audioPath, finalPath, finalExt);
const stat = fs.statSync(finalPath);
const metadata = {
id: stopped.recordingId,
meetingId,
filename: finalFilename,
originalFilename,
mimetype: getRecordingMimeTypeFromExtension(finalExt),
size: stat.size,
userId,
host,
participants,
serverAudio: {
audioTrackCount: stopped.audioTrackCount,
startedAt: new Date(stopped.createdAt).toISOString(),
stoppedAt: new Date(stopped.stoppedAt).toISOString()
},
uploadedAt: new Date().toISOString()
};
fs.writeFileSync(path.join(meetingDir, `${finalFilename}.json`), JSON.stringify(metadata, null, 2));
removeFileIfExists(request.file.path);
removeFileIfExists(stopped.audioPath);
res.json({
success: true,
recordingId: stopped.recordingId,
meetingId,
filename: finalFilename,
originalFilename,
size: stat.size,
merged: true,
url: `/api/recordings/${encodeURIComponent(meetingId)}/${encodeURIComponent(finalFilename)}/download`
});
} catch (mergeError) {
removeFileIfExists(request.file && request.file.path);
if (stopped) {
removeFileIfExists(stopped.audioPath);
}
log(LogLevel.error, 'Failed to stop server audio recording:', mergeError);
res.status(500).json({
success: false,
message: mergeError instanceof Error ? mergeError.message : 'Failed to stop server audio recording'
});
}
});
});
app.get('/api/recordings', (_req: express.Request, res: express.Response) => {
try {
const recordings = listRecordings(recordingRoot);
@@ -394,6 +763,9 @@ export const createServer = (config: Options): express.Express => {
const recordingId = uuid();
const meetingId = sanitizePathSegment(request.body.meetingId, 'unknown');
const originalFilename = path.basename(request.body.filename || request.file.originalname || `recording${ext}`);
const userId = sanitizeMetadataString(request.body.userId, 120);
const host = sanitizeRecordingPerson(request.body.host, 'host') || buildFallbackRecordingHost(userId);
const participants = sanitizeRecordingParticipants(request.body.participants);
const finalFilename = `${new Date().toISOString().replace(/[:.]/g, '-')}-${recordingId}${ext}`;
const meetingDir = path.join(recordingRoot, meetingId);
const finalPath = path.join(meetingDir, finalFilename);
@@ -425,9 +797,11 @@ export const createServer = (config: Options): express.Express => {
meetingId,
filename: finalFilename,
originalFilename,
mimetype: normalizeMimeType(request.file.mimetype),
mimetype: getRecordingMimeTypeFromExtension(ext),
size: request.file.size,
userId: request.body.userId || '',
userId,
host,
participants,
uploadedAt: new Date().toISOString()
};
fs.writeFile(path.join(meetingDir, `${finalFilename}.json`), JSON.stringify(metadata, null, 2), () => undefined);
@@ -516,6 +890,16 @@ export const createServer = (config: Options): express.Express => {
}
const metadata = readRecordingMetadata(targetMetadataPath);
const nextUserId = typeof req.body.userId === 'string' ? sanitizeMetadataString(req.body.userId, 120) : metadata.userId || '';
const shouldSyncHostFromUserId = !metadata.host
|| metadata.host.userId === metadata.userId
|| metadata.host.id === metadata.userId;
const nextHost = req.body.host !== undefined
? sanitizeRecordingPerson(req.body.host, 'host') || buildFallbackRecordingHost(nextUserId)
: shouldSyncHostFromUserId ? buildFallbackRecordingHost(nextUserId) : metadata.host;
const nextParticipants = req.body.participants !== undefined
? sanitizeRecordingParticipants(req.body.participants)
: Array.isArray(metadata.participants) ? metadata.participants : [];
const nextMetadata = {
...metadata,
meetingId: nextMeetingId,
@@ -523,8 +907,10 @@ export const createServer = (config: Options): express.Express => {
originalFilename: typeof req.body.originalFilename === 'string' && req.body.originalFilename.trim()
? path.basename(req.body.originalFilename.trim())
: metadata.originalFilename || filename,
userId: typeof req.body.userId === 'string' ? req.body.userId.trim() : metadata.userId || '',
mimetype: metadata.mimetype || (ext === '.mp4' ? 'video/mp4' : 'video/webm'),
userId: nextUserId,
host: nextHost,
participants: nextParticipants,
mimetype: metadata.mimetype || getRecordingMimeTypeFromExtension(ext),
size: fs.statSync(targetPath).size,
uploadedAt: metadata.uploadedAt || fs.statSync(targetPath).birthtime.toISOString(),
updatedAt: new Date().toISOString()

View File

@@ -6,7 +6,7 @@ const router: express.Router = express.Router();
// 不需要会话ID的路由
router.get('/connection-ids', handler.getAllConnectionIds);
router.get('/users', handler.getOnlineUsers);
router.get('/rooms', handler.onGetConnections);
// router.get('/rooms', handler.onGetConnections);
// 需要会话ID的路由
router.use(handler.checkSessionId);

12
src/types/werift-nonstandard.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
declare module 'werift/nonstandard' {
export class MediaRecorder {
onError: {
subscribe: (execute: (error: Error) => void) => { unSubscribe: () => void };
};
constructor(props: any);
addTrack(track: any): Promise<void>;
stop(): Promise<void>;
}
}

View File

@@ -3,10 +3,12 @@
"exclude": ["node_modules", "**/*.spec.ts"],
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"target": "es5",
"lib": ["dom","es5"],
"sourceMap": true,
"outDir":"build",
"rootDir":"src"
"rootDir":"src",
"skipLibCheck": true
}
}