Compare commits

16 Commits

128 changed files with 8844 additions and 1772 deletions

5
.gitignore vendored
View File

@@ -43,6 +43,9 @@ node_modules/
# Coverage
coverage/
recordings/
!client/public/recordings/
!client/public/recordings/**
*.lcov
.nyc_output
@@ -184,4 +187,4 @@ out/
.history/
# Built Visual Studio Code Extensions
*.vsix
*.vsix

View File

@@ -80,7 +80,9 @@ export default {
],
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
// moduleNameMapper: {},
moduleNameMapper: {
"^/module/(.*)$": "<rootDir>/src/$1"
},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],

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

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

Before

Width:  |  Height:  |  Size: 472 KiB

After

Width:  |  Height:  |  Size: 472 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 166 KiB

View File

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 152 KiB

View File

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 274 KiB

View File

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -1,10 +1,13 @@
import { createLogger } from '../../shared/logger.js';
const logger = createLogger('chat');
/**
* 消息模块
* 处理聊天消息的发送接收和显示
*/
import { showNotification, generateId } from './utils.js';
import store from './store.js';
import { mockMessages } from './models.js';
import { showNotification, generateId } from '../../shared/utils.js';
import store from '../store.js';
import { mockMessages } from '../models.js';
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
@@ -72,7 +75,7 @@ function sendChatMessage(message) {
* @param {Object} data - 消息数据
*/
function handleChatMessage(data) {
console.log('处理聊天:', data);
logger.debug('处理聊天:', data);
addMessage(data);
const isImage = data.content && data.content.startsWith('data:image/');

View File

@@ -0,0 +1,77 @@
import { createTextElement, textValue } from '../../shared/dom.js';
export function createMessageElement(message, formatTimestamp) {
const messageDiv = document.createElement('div');
let messageClass = 'chat-bubble';
if (message.type === 'system') {
messageClass += ' message-system';
} else if (message.isSelf) {
messageClass += ' message-self';
} else {
messageClass += ' message-other';
}
messageDiv.className = messageClass;
messageDiv.dataset.messageId = message.id;
const header = document.createElement('div');
header.className = 'message-header';
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;
}
export function renderChatMessagesInto(container, messages, formatTimestamp) {
if (!container) return;
container.innerHTML = '';
const startTimeElement = document.createElement('div');
startTimeElement.className = 'text-center text-xs text-gray-500 my-4';
const startTime = messages[0]?.timestamp || new Date().toISOString();
startTimeElement.textContent = `\u901a\u8bdd\u5f00\u59cb ${formatTimestamp(startTime)}`;
container.appendChild(startTimeElement);
messages.forEach(message => {
container.appendChild(createMessageElement(message, formatTimestamp));
});
container.scrollTop = container.scrollHeight;
}

View File

@@ -1,12 +1,15 @@
import { showNotification } from './utils.js';
import { showNotification } from '../shared/utils.js';
import store from './store.js';
import {
fetchConnectionDirectory,
fetchOnlineUsers,
renderConnectionIds,
renderOnlineUsers
} from './connect-directory.js';
import { createProfileSettingsController } from './profile-settings.js';
} from './signaling/connect-directory.js';
import { createProfileSettingsController } from './controllers/profile-settings.js';
import { createLogger } from '../shared/logger.js';
const logger = createLogger('connectview');
let onWsStatusChange = null;
let cachedOnlineUsers = [];
@@ -45,9 +48,9 @@ export async function initWebSocket() {
store.syncSocketUserInfo();
updateWsStatus(true);
await refreshOnlineUsers();
console.log('WebSocket initialized from connectview');
logger.debug('WebSocket initialized from connectview');
} catch (error) {
console.error('Failed to initialize WebSocket:', error);
logger.error('Failed to initialize WebSocket:', error);
updateWsStatus(false);
showNotification('WebSocket连接失败请刷新页面重试', 'error');
}
@@ -62,7 +65,7 @@ async function refreshOnlineUsers(silent = true) {
showNotification(`当前共有 ${cachedOnlineUsers.length} 个WebSocket用户在线`);
}
} catch (error) {
console.error('Error fetching online users:', error);
logger.error('Error fetching online users:', error);
if (!silent) {
showNotification('获取在线用户失败', 'error');
}
@@ -78,7 +81,7 @@ async function getAllConnectionIds() {
updateConnectionIdList(connectionIds);
updateOnlineUsersList(cachedOnlineUsers);
} catch (error) {
console.error('Error fetching connection IDs:', error);
logger.error('Error fetching connection IDs:', error);
showNotification('获取连接信息失败', 'error');
}
}
@@ -104,7 +107,7 @@ function getCurrentUserId() {
const settings = JSON.parse(localStorage.getItem('userSettings') || '{}');
return settings.userId || settings.id || '';
} catch (error) {
console.error('Error parsing current user settings:', error);
logger.error('Error parsing current user settings:', error);
return '';
}
}

View File

@@ -21,15 +21,13 @@ export function createCallViewController({ store, chatMessage, notify }) {
toggleVideo();
}
function toggleRecording() {
const state = store.getState();
const currentState = state.session.localUser.mediaState.recording || false;
store.updateLocalMedia('recording', !currentState);
if (!currentState) {
notify('\u5f00\u59cb\u5f55\u5236');
} else {
notify('\u505c\u6b62\u5f55\u5236');
async function toggleRecording() {
try {
const result = await store.toggleRecording();
notify(result.message);
}
catch (error) {
notify(error.message || '\u5f55\u5236\u5931\u8d25');
}
}

View File

@@ -1,3 +1,6 @@
import { createLogger } from '../../shared/logger.js';
const logger = createLogger('invite');
const DEFAULT_CALLER_NAME = '\u9080\u8bf7\u65b9';
const DEFAULT_CALLER_AVATAR = '/images/p2.png';
const DEFAULT_APPLY_REASON = '\u672a\u586b\u5199';
@@ -127,7 +130,7 @@ export function createInviteController({
targetUserId: pendingInvite.inviterUserId
});
} catch (error) {
console.error('Error accepting invite:', error);
logger.error('Error accepting invite:', error);
notify('\u63a5\u53d7\u9080\u8bf7\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5', 'error');
return;
}
@@ -152,7 +155,7 @@ export function createInviteController({
targetUserId: pendingInvite.inviterUserId
});
} catch (error) {
console.error('Error rejecting invite:', error);
logger.error('Error rejecting invite:', error);
}
}

View File

@@ -1,3 +1,6 @@
import { createLogger } from '../../shared/logger.js';
const logger = createLogger('profile');
const DEFAULT_AVATAR = '/images/p1.png';
const MAX_AVATAR_SIZE = 2 * 1024 * 1024;
const USER_ID_PREFIX = 'user_';
@@ -100,7 +103,7 @@ export function createProfileSettingsController({ store, notify }) {
updateUserName(settings.name || '\u6211');
setAvatarPreview(settings.avatar || DEFAULT_AVATAR);
} catch (error) {
console.error('Error loading user settings:', error);
logger.error('Error loading user settings:', error);
setAvatarPreview(DEFAULT_AVATAR);
}
}
@@ -152,7 +155,7 @@ export function createProfileSettingsController({ store, notify }) {
saveSettings();
notify('\u5934\u50cf\u4e0a\u4f20\u6210\u529f', 'success');
} catch (error) {
console.error('Error uploading avatar:', error);
logger.error('Error uploading avatar:', error);
setAvatarPreview(DEFAULT_AVATAR);
notify('\u5934\u50cf\u4e0a\u4f20\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5', 'error');
}

View File

@@ -7,7 +7,7 @@
<title>VideoCall - 一对一视频通话</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="/styles/style.css">
</head>
<body class="h-screen w-screen flex flex-col text-white bg-grid relative">
@@ -726,8 +726,8 @@
</div>
<!-- 引入模块化JavaScript文件 -->
<script type="module" src="connectview.js"></script>
<script type="module" src="main.js"></script>
<script type="module" src="/call/connectview.js"></script>
<script type="module" src="/call/main.js"></script>
</body>

View File

@@ -1,14 +1,17 @@
import store from './store.js';
import UIRenderer from './renderer.js';
import { showNotification, randomMeetingId } from './utils.js';
import chatMessage from './chatmessage.js';
import { createCallViewController } from './call-view-controller.js';
import UIRenderer from './renderers/renderer.js';
import { showNotification, randomMeetingId } from '../shared/utils.js';
import chatMessage from './chat/chatmessage.js';
import { createCallViewController } from './controllers/call-view-controller.js';
import {
bindConnectViewEvents,
initWebSocket,
loadUserSettings
} from './connectview.js';
import { createInviteController } from './invite-controller.js';
import { createInviteController } from './controllers/invite-controller.js';
import { createLogger } from '../shared/logger.js';
const logger = createLogger('main');
let connectionId = '';
let currentView = 'connect';
@@ -43,10 +46,10 @@ async function switchToCallView(targetConnectionId) {
renderer.renderHeaderTitle();
callViewController.bindDomEvents();
console.log('Video call app initialized successfully');
logger.debug('Video call app initialized successfully');
return true;
} catch (error) {
console.error('Error initializing app:', error);
logger.error('Error initializing app:', error);
showNotification('初始化失败,请刷新页面重试', 'error');
return false;
}
@@ -116,9 +119,9 @@ window.addEventListener('DOMContentLoaded', async () => {
inviteController.showCallRequestDialog(invitePayload);
}
console.log('SPA initialized, showing connect view');
logger.debug('SPA initialized, showing connect view');
} catch (error) {
console.error('Error initializing SPA:', error);
logger.error('Error initializing SPA:', error);
showNotification('初始化失败,请刷新页面重试', 'error');
}
});

View File

@@ -0,0 +1,388 @@
const DEFAULT_WIDTH = 1280;
const DEFAULT_HEIGHT = 720;
const DEFAULT_FPS = 30;
const MIME_TYPE_CANDIDATES = [
{ mimeType: 'video/mp4;codecs=avc1.42E01E,mp4a.40.2', extension: 'mp4' },
{ mimeType: 'video/mp4', extension: 'mp4' },
{ mimeType: 'video/webm;codecs=vp9,opus', extension: 'webm' },
{ mimeType: 'video/webm;codecs=vp8,opus', extension: 'webm' },
{ mimeType: 'video/webm', extension: 'webm' }
];
function getSupportedFormat(mediaRecorderCtor) {
if (!mediaRecorderCtor || typeof mediaRecorderCtor.isTypeSupported !== 'function') {
return {
mimeType: '',
extension: 'webm'
};
}
return MIME_TYPE_CANDIDATES.find(format => mediaRecorderCtor.isTypeSupported(format.mimeType)) || {
mimeType: '',
extension: 'webm'
};
}
function isElementVisible(element) {
if (!element || element.classList.contains('hidden')) {
return false;
}
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function drawVideoCover(context, video, x, y, width, height) {
const videoWidth = video.videoWidth || width;
const videoHeight = video.videoHeight || height;
const sourceRatio = videoWidth / videoHeight;
const targetRatio = width / height;
let sourceX = 0;
let sourceY = 0;
let sourceWidth = videoWidth;
let sourceHeight = videoHeight;
if (sourceRatio > targetRatio) {
sourceWidth = videoHeight * targetRatio;
sourceX = (videoWidth - sourceWidth) / 2;
} else {
sourceHeight = videoWidth / targetRatio;
sourceY = (videoHeight - sourceHeight) / 2;
}
context.drawImage(video, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
}
function drawEmptyFrame(context, canvas) {
context.fillStyle = '#111827';
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = '#9ca3af';
context.font = '24px sans-serif';
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText('等待会议画面...', canvas.width / 2, canvas.height / 2);
}
function drawGrid(context, videos, canvas) {
const columns = Math.ceil(Math.sqrt(videos.length));
const rows = Math.ceil(videos.length / columns);
const gap = 8;
const tileWidth = (canvas.width - gap * (columns - 1)) / columns;
const tileHeight = (canvas.height - gap * (rows - 1)) / rows;
videos.forEach((video, index) => {
const column = index % columns;
const row = Math.floor(index / columns);
const x = column * (tileWidth + gap);
const y = row * (tileHeight + gap);
drawVideoCover(context, video, x, y, tileWidth, tileHeight);
});
}
function drawLocalPreview(context, localVideo, canvas) {
const previewWidth = Math.floor(canvas.width * 0.22);
const previewHeight = Math.floor(previewWidth * 9 / 16);
const margin = 24;
const x = canvas.width - previewWidth - margin;
const y = canvas.height - previewHeight - margin;
context.fillStyle = 'rgba(0, 0, 0, 0.4)';
context.fillRect(x - 4, y - 4, previewWidth + 8, previewHeight + 8);
drawVideoCover(context, localVideo, x, y, previewWidth, previewHeight);
}
function collectStreams({ localStream, remoteStream, remoteStreams } = {}) {
return [
localStream,
remoteStream,
...Object.values(remoteStreams || {})
].filter(Boolean);
}
function collectLiveAudioTracks(streams) {
return streams.flatMap(stream => stream.getAudioTracks())
.filter(track => track.readyState !== 'ended');
}
export class MeetingRecorder {
constructor({
documentRef = document,
windowRef = window,
width = DEFAULT_WIDTH,
height = DEFAULT_HEIGHT,
fps = DEFAULT_FPS
} = {}) {
this.document = documentRef;
this.window = windowRef;
this.width = width;
this.height = height;
this.fps = fps;
this.mediaRecorder = null;
this.chunks = [];
this.animationFrameId = null;
this.audioContext = null;
this.audioSources = [];
this.recordingStream = null;
this.connectionId = '';
this.layout = 'grid';
this.onChunk = null;
this.storeChunks = true;
this.mixedAudioDestination = null;
this.mixedAudioTrackIds = new Set();
}
isSupported() {
return Boolean(
this.window.MediaRecorder &&
this.document.createElement('canvas').captureStream
);
}
isRecording() {
return Boolean(this.mediaRecorder && this.mediaRecorder.state !== 'inactive');
}
async start({ localStream, remoteStream, remoteStreams, connectionId, layout, onChunk, storeChunks } = {}) {
if (this.isRecording()) {
throw new Error('会议正在录制中');
}
if (!this.isSupported()) {
throw new Error('当前浏览器不支持会议录制');
}
const canvas = this.document.createElement('canvas');
canvas.width = this.width;
canvas.height = this.height;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('无法创建录制画布');
}
this.connectionId = connectionId || '';
this.layout = layout || 'grid';
this.onChunk = typeof onChunk === 'function' ? onChunk : null;
this.storeChunks = storeChunks !== false;
this.chunks = [];
this.mixedAudioTrackIds = new Set();
this.canvas = canvas;
this.context = context;
const canvasStream = canvas.captureStream(this.fps);
const streams = collectStreams({ localStream, remoteStream, remoteStreams });
const audioTrack = this.createMixedAudioTrack(streams);
const tracks = [
...canvasStream.getVideoTracks(),
...(audioTrack ? [audioTrack] : [])
];
this.recordingStream = new this.window.MediaStream(tracks);
try {
this.startDrawing();
this.startMediaRecorder(this.recordingStream);
}
catch (error) {
this.cleanup();
throw error;
}
}
syncAudio({ localStream, remoteStream, remoteStreams } = {}) {
if (!this.isRecording() || !this.audioContext || !this.mixedAudioDestination) {
return;
}
const streams = collectStreams({ localStream, remoteStream, remoteStreams });
const audioTracks = collectLiveAudioTracks(streams);
audioTracks.forEach(track => this._connectAudioTrack(track));
}
stop() {
if (!this.isRecording()) {
return Promise.resolve(null);
}
return new Promise((resolve, reject) => {
this.pendingStop = { resolve, reject };
this.mediaRecorder.stop();
});
}
createMixedAudioTrack(streams) {
const audioTracks = collectLiveAudioTracks(streams);
if (audioTracks.length === 0) {
return null;
}
const AudioContextCtor = this.window.AudioContext || this.window.webkitAudioContext;
if (!AudioContextCtor) {
return audioTracks[0].clone ? audioTracks[0].clone() : audioTracks[0];
}
this.audioContext = new AudioContextCtor();
this.mixedAudioDestination = this.audioContext.createMediaStreamDestination();
audioTracks.forEach(track => this._connectAudioTrack(track));
return this.mixedAudioDestination.stream.getAudioTracks()[0] || null;
}
_connectAudioTrack(track) {
if (!track || track.readyState === 'ended') {
return;
}
const trackId = track.id || `${track.kind}-${Date.now()}`;
if (this.mixedAudioTrackIds.has(trackId)) {
return;
}
this.mixedAudioTrackIds.add(trackId);
const sourceStream = new this.window.MediaStream([track]);
const source = this.audioContext.createMediaStreamSource(sourceStream);
source.connect(this.mixedAudioDestination);
this.audioSources.push(source);
}
startMediaRecorder(stream) {
const MediaRecorderCtor = this.window.MediaRecorder;
const format = getSupportedFormat(MediaRecorderCtor);
const options = format.mimeType ? { mimeType: format.mimeType } : {};
this.fileExtension = format.extension;
this.mediaRecorder = new MediaRecorderCtor(stream, options);
this.mediaRecorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
if (this.storeChunks) {
this.chunks.push(event.data);
}
if (this.onChunk) {
try {
this.onChunk(event.data);
}
catch (_error) {
// Ignore chunk callback failures so recording can continue.
}
}
}
};
this.mediaRecorder.onerror = (event) => {
if (this.pendingStop) {
this.pendingStop.reject(event.error || new Error('录制失败'));
this.pendingStop = null;
}
this.cleanup();
};
this.mediaRecorder.onstop = () => {
const filename = this.buildFilename();
const mimeType = this.mediaRecorder.mimeType || 'video/webm';
const blob = this.storeChunks ? new Blob(this.chunks, { type: mimeType }) : null;
this.cleanup();
if (this.pendingStop) {
this.pendingStop.resolve({ blob, filename, mimeType });
this.pendingStop = null;
}
};
this.mediaRecorder.start(1000);
}
startDrawing() {
const draw = () => {
this.drawFrame();
this.animationFrameId = this.window.requestAnimationFrame(draw);
};
draw();
}
drawFrame() {
const context = this.context;
const canvas = this.canvas;
const videos = this.getRecordableVideos();
const localVideo = videos.find(video => video.id === 'localVideo');
const remoteVideos = videos.filter(video => video !== localVideo);
context.fillStyle = '#020617';
context.fillRect(0, 0, canvas.width, canvas.height);
if (this.layout === 'host-only') {
if (localVideo) {
drawVideoCover(context, localVideo, 0, 0, canvas.width, canvas.height);
return;
}
drawEmptyFrame(context, canvas);
return;
}
if (remoteVideos.length > 0) {
drawGrid(context, remoteVideos, canvas);
if (localVideo) {
drawLocalPreview(context, localVideo, canvas);
}
return;
}
if (localVideo) {
drawVideoCover(context, localVideo, 0, 0, canvas.width, canvas.height);
return;
}
drawEmptyFrame(context, canvas);
}
getRecordableVideos() {
return Array.from(this.document.querySelectorAll('#participantGrid video, #remoteVideo, #localVideo'))
.filter(video => video.srcObject && isElementVisible(video) && video.readyState >= 2);
}
download(blob, filename = this.buildFilename()) {
const url = this.window.URL.createObjectURL(blob);
const link = this.document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = 'none';
this.document.body.appendChild(link);
link.click();
link.remove();
this.window.setTimeout(() => {
this.window.URL.revokeObjectURL(url);
}, 1000);
return filename;
}
buildFilename() {
const datePart = new Date().toISOString().replace(/[:.]/g, '-');
const meetingPart = this.connectionId ? `-${this.connectionId}` : '';
return `meeting-recording${meetingPart}-${datePart}.${this.fileExtension || 'webm'}`;
}
cleanup() {
if (this.animationFrameId) {
this.window.cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
if (this.recordingStream) {
this.recordingStream.getTracks().forEach(track => track.stop());
this.recordingStream = null;
}
if (this.audioContext) {
this.audioContext.close();
this.audioContext = null;
}
this.audioSources = [];
this.mixedAudioDestination = null;
this.mixedAudioTrackIds = new Set();
this.mediaRecorder = null;
this.canvas = null;
this.context = null;
this.chunks = [];
this.onChunk = null;
this.storeChunks = true;
}
}

View File

@@ -1,4 +1,7 @@
import { createParticipantTile, getParticipantTile } from './renderer-participant-grid.js';
import { createParticipantTile, getParticipantTile } from '../participants/renderer-participant-grid.js';
import { createLogger } from '../../shared/logger.js';
const logger = createLogger('renderer-media');
export function getVideoResolution(track) {
if (track && track.getSettings) {
@@ -47,18 +50,18 @@ export function renderParticipantStreamMedia({
if (!tile) {
tile = createParticipantTile(connectionId, displayName);
grid.appendChild(tile);
console.log(`Created participant video tile for ${connectionId}`);
logger.debug(`Created participant video tile for ${connectionId}`);
}
const video = tile.querySelector('video');
if (video && stream) {
if (video.srcObject === stream) {
console.log(`Same stream for participant ${connectionId}, ensuring playback`);
video.play().catch(error => console.log('Auto-play prevented:', error.message));
logger.debug(`Same stream for participant ${connectionId}, ensuring playback`);
video.play().catch(error => logger.debug('Auto-play prevented:', error.message));
} else {
video.srcObject = stream;
video.play().catch(error => console.log('Auto-play prevented:', error.message));
console.log(`Set remote stream for participant tile ${connectionId}`);
video.play().catch(error => logger.debug('Auto-play prevented:', error.message));
logger.debug(`Set remote stream for participant tile ${connectionId}`);
}
}
@@ -86,15 +89,15 @@ export function renderSingleRemoteStreamMedia({
connectingOverlay
}) {
if (!remoteVideo || !stream) {
console.error('Either remoteVideo element or stream is missing');
logger.error('Either remoteVideo element or stream is missing');
return;
}
console.log('Rendering remote stream:', stream, 'tracks:', stream.getTracks().map(track => `${track.kind}(${track.readyState})`));
logger.debug('Rendering remote stream:', stream, 'tracks:', stream.getTracks().map(track => `${track.kind}(${track.readyState})`));
if (remoteVideo.srcObject === stream) {
console.log('Same stream object, track added - ensuring playback');
remoteVideo.play().catch(error => console.log('Auto-play prevented:', error.message));
logger.debug('Same stream object, track added - ensuring playback');
remoteVideo.play().catch(error => logger.debug('Auto-play prevented:', error.message));
return;
}
@@ -103,7 +106,7 @@ export function renderSingleRemoteStreamMedia({
remoteVideo.playsinline = true;
remoteVideo.muted = false;
remoteVideo.play().catch(error => {
console.log('Auto-play prevented, will retry on interaction:', error.message);
logger.debug('Auto-play prevented, will retry on interaction:', error.message);
});
if (disconnectedOverlay) {
@@ -112,10 +115,10 @@ export function renderSingleRemoteStreamMedia({
const videoTracks = stream.getVideoTracks();
const audioTracks = stream.getAudioTracks();
console.log(`Stream has ${videoTracks.length} video tracks, ${audioTracks.length} audio tracks`);
logger.debug(`Stream has ${videoTracks.length} video tracks, ${audioTracks.length} audio tracks`);
if (videoTracks.length === 0) {
console.log('Audio-only stream, waiting for video track...');
logger.debug('Audio-only stream, waiting for video track...');
return;
}
@@ -165,7 +168,7 @@ export function removeParticipantTile({
video.srcObject = null;
}
tile.remove();
console.log(`Removed participant video tile for ${connectionId}`);
logger.debug(`Removed participant video tile for ${connectionId}`);
}
const remainingTiles = grid.querySelectorAll('[data-participant-id]');

View File

@@ -0,0 +1,175 @@
import { createLogger } from '../../shared/logger.js';
const logger = createLogger('server-recording-peer');
export class ServerRecordingPeer {
constructor({
rtcConfiguration,
getLocalStream,
getSignaling,
getConnectionId,
getParticipantId
}) {
this.rtcConfiguration = rtcConfiguration;
this.getLocalStream = getLocalStream;
this.getSignaling = getSignaling;
this.getConnectionId = getConnectionId;
this.getParticipantId = getParticipantId;
this.peers = new Map();
}
async start(request) {
if (!request || !request.recordingId) {
return;
}
this.stop(request.recordingId);
const localStream = this.getLocalStream();
const tracks = localStream ? localStream.getTracks().filter(track => track.readyState !== 'ended') : [];
if (tracks.length === 0) {
this._sendStatus(request, 'no-local-media');
return;
}
const pc = new RTCPeerConnection(this.rtcConfiguration);
const state = {
pc,
recordingId: request.recordingId,
connectionId: request.connectionId || this.getConnectionId(),
pendingCandidates: []
};
this.peers.set(request.recordingId, state);
pc.onicecandidate = (event) => {
if (!event.candidate) {
return;
}
this._sendCandidate(state, event.candidate);
};
pc.onconnectionstatechange = () => {
logger.debug(`recording peer ${request.recordingId} state: ${pc.connectionState}`);
};
tracks.forEach(track => {
pc.addTransceiver(track, {
direction: 'sendonly',
streams: localStream ? [localStream] : []
});
});
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
this._sendOffer(state);
}
async applyAnswer(answer) {
const state = this.peers.get(answer?.recordingId);
if (!state || !answer?.sdp) {
return;
}
await state.pc.setRemoteDescription(new RTCSessionDescription({
type: 'answer',
sdp: answer.sdp
}));
await this._flushPendingCandidates(state);
}
async addIceCandidate(candidate) {
const state = this.peers.get(candidate?.recordingId);
if (!state || !candidate?.candidate) {
return;
}
const iceCandidate = new RTCIceCandidate({
candidate: candidate.candidate,
sdpMid: candidate.sdpMid,
sdpMLineIndex: candidate.sdpMLineIndex
});
if (!state.pc.remoteDescription) {
state.pendingCandidates.push(iceCandidate);
return;
}
await state.pc.addIceCandidate(iceCandidate);
}
stop(recordingId) {
if (!recordingId) {
this.peers.forEach(peerState => this._closePeer(peerState));
this.peers.clear();
return;
}
const state = this.peers.get(recordingId);
if (!state) {
return;
}
this._closePeer(state);
this.peers.delete(recordingId);
}
_closePeer(state) {
state.pendingCandidates = [];
state.pc.close();
}
async _flushPendingCandidates(state) {
if (!state?.pc?.remoteDescription || !state.pendingCandidates.length) {
return;
}
const pendingCandidates = state.pendingCandidates.splice(0, state.pendingCandidates.length);
for (const candidate of pendingCandidates) {
await state.pc.addIceCandidate(candidate);
}
}
_sendOffer(state) {
const signaling = this.getSignaling();
if (!signaling || typeof signaling.sendRecordingOffer !== 'function') {
return;
}
signaling.sendRecordingOffer({
recordingId: state.recordingId,
connectionId: state.connectionId,
participantId: this.getParticipantId() || '',
sdp: state.pc.localDescription?.sdp || ''
});
}
_sendCandidate(state, candidate) {
const signaling = this.getSignaling();
if (!signaling || typeof signaling.sendRecordingCandidate !== 'function') {
return;
}
signaling.sendRecordingCandidate({
recordingId: state.recordingId,
connectionId: state.connectionId,
participantId: this.getParticipantId() || '',
candidate: candidate.candidate,
sdpMid: candidate.sdpMid,
sdpMLineIndex: candidate.sdpMLineIndex
});
}
_sendStatus(request, status) {
const signaling = this.getSignaling();
if (!signaling || typeof signaling.sendRecordingStatus !== 'function') {
return;
}
signaling.sendRecordingStatus({
recordingId: request.recordingId,
connectionId: request.connectionId || this.getConnectionId(),
participantId: this.getParticipantId() || '',
status
});
}
}

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,7 +1,7 @@
import { formatTime, formatTimestamp, toggleElement, toggleButtonState } from './utils.js';
import { mockCallSession } from './models.js';
import chatMessage from './chatmessage.js';
import store from './store.js';
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';
import {
buildUserCountLabel,
createUserEntryElement,
@@ -10,11 +10,11 @@ import {
getNetworkQualityDisplay,
getRemoteVideoPlaceholderText
} from './renderer-ui.js';
import { renderChatMessagesInto } from './renderer-chat.js';
import { renderChatMessagesInto } from '../chat/renderer-chat.js';
import {
updateParticipantTileName as syncParticipantTileName,
updateParticipantTilePlaceholder
} from './renderer-participant-grid.js';
} from '../participants/renderer-participant-grid.js';
import {
adjustVideoSize,
clearParticipantGrid,
@@ -22,7 +22,10 @@ import {
removeParticipantTile,
renderParticipantStreamMedia,
renderSingleRemoteStreamMedia
} from './renderer-media.js';
} from '../media/renderer-media.js';
import { createLogger } from '../../shared/logger.js';
const logger = createLogger('renderer');
const GRID_LAYOUT = {
maxColumns: 3,
@@ -317,13 +320,13 @@ class UIRenderer {
this.elements.localVideo.srcObject = stream;
this.elements.localVideo.autoplay = true;
this.elements.localVideo.muted = true;
console.log('srcObject set successfully:', this.elements.localVideo.srcObject);
logger.debug('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');
logger.error('Either localVideo element or stream is missing');
}
}
@@ -352,7 +355,7 @@ class UIRenderer {
const grid = this.elements.participantGrid;
if (!grid) return;
updateParticipantTilePlaceholder(grid, participantId, showPlaceholder);
console.log(`Updated placeholder for participant ${participantId}: ${showPlaceholder ? 'shown' : 'hidden'}`);
logger.debug(`Updated placeholder for participant ${participantId}: ${showPlaceholder ? 'shown' : 'hidden'}`);
}
syncParticipantTileNames(participants) {
@@ -369,7 +372,7 @@ class UIRenderer {
if (!grid) return;
syncParticipantTileName(grid, participantId, name);
if (name) {
console.log(`Updated tile name for participant ${participantId}: ${name}`);
logger.debug(`Updated tile name for participant ${participantId}: ${name}`);
}
}
@@ -508,13 +511,13 @@ class UIRenderer {
}
renderCallEnded() {
console.log('Call ended');
logger.debug('Call ended');
clearParticipantGrid(this.elements.participantGrid);
window.location.href = './endcall/endcall.html';
window.location.href = '/endcall/';
}
renderParticipantLeft(connectionId) {
console.log(`Participant left: ${connectionId}, updating UI`);
logger.debug(`Participant left: ${connectionId}, updating UI`);
removeParticipantTile({
grid: this.elements.participantGrid,
connectionId,

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

@@ -1,4 +1,7 @@
import { Signaling, WebSocketSignaling } from "../../module/signaling.js";
import { Signaling, WebSocketSignaling } from '/module/core/signaling.js';
import { createLogger } from '../../shared/logger.js';
const logger = createLogger('signaling');
const INVITE_EVENT_NAMES = Object.freeze([
'invite-call',
@@ -72,7 +75,7 @@ function readStoredSocketUserInfo() {
try {
return JSON.parse(localStorage.getItem('userSettings') || '{}');
} catch (error) {
console.error('Error parsing user settings:', error);
logger.error('Error parsing user settings:', error);
return {};
}
}
@@ -88,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

@@ -1,13 +1,31 @@
import { mockCallSession } from './models.js';
import { RenderStreaming } from "../../module/renderstreaming.js";
import { getServerConfig, getRTCConfiguration } from "../js/config.js";
import { showNotification, generateId } from './utils.js';
import chatMessage from './chatmessage.js';
import { DEFAULT_PARTICIPANT_AVATAR, DEFAULT_PARTICIPANT_NAME, buildParticipantsSyncData, omitParticipant, removeParticipant, upsertParticipant } from './participants.js';
import { AUDIO_CONFIG, VAD_CONFIG, VIDEO_ONLY_CONSTRAINT, buildVideoConstraints, getAdaptiveVideoBitrate, getResolutionLabel, getTargetResolutionBitrate } from './media-config.js';
import { buildStatsLogPayload, createAudioAnalyser, getAudioLevel } from './media-monitoring.js';
import { bindInviteSocketEvents, buildSocketUserInfoPayload, createSignalingInstance, ensureSignalingStarted, getActiveSignalingInstance, sendInviteSignal, sendSocketUserInfo } from './signaling-session.js';
import { getNetworkQualityFromSummary, summarizeInboundStats } from './webrtc-stats.js';
import { RenderStreaming } from '/module/core/renderstreaming.js';
import { getServerConfig, getRTCConfiguration } from '../render-streaming/config.js';
import { showNotification, generateId } from '../shared/utils.js';
import chatMessage from './chat/chatmessage.js';
import { DEFAULT_PARTICIPANT_AVATAR, DEFAULT_PARTICIPANT_NAME, buildParticipantsSyncData, omitParticipant, removeParticipant, upsertParticipant } from './participants/participants.js';
import { AUDIO_CONFIG, VAD_CONFIG, VIDEO_ONLY_CONSTRAINT, buildVideoConstraints, getAdaptiveVideoBitrate, getResolutionLabel, getTargetResolutionBitrate } from './media/media-config.js';
import { buildStatsLogPayload, createAudioAnalyser, getAudioLevel } from './media/media-monitoring.js';
import { bindInviteSocketEvents, buildSocketUserInfoPayload, createSignalingInstance, ensureSignalingStarted, getActiveSignalingInstance, sendInviteSignal, sendSocketUserInfo } from './signaling/signaling-session.js';
import { getNetworkQualityFromSummary, summarizeInboundStats } from './media/webrtc-stats.js';
import { createLogger } from '../shared/logger.js';
import { MeetingRecorder } from './media/meeting-recorder.js';
import { ServerRecordingPeer } from './media/server-recording-peer.js';
const logger = createLogger('store');
const MEDIA_STATE_KEYS = ['audio', 'video', 'screenShare', 'recording', 'isSpeaking'];
function hasMediaStateChanged(current = {}, next = {}) {
if (!next || typeof next !== 'object') {
return false;
}
return MEDIA_STATE_KEYS.some(key => (
Object.prototype.hasOwnProperty.call(next, key)
&& Boolean(current?.[key]) !== Boolean(next[key])
));
}
class CallStateManager {
constructor() {
this.state = {
@@ -24,6 +42,10 @@ class CallStateManager {
this.listeners = [];
this.socketEventHandlers = {};
this._inviteEventSignaling = null;
this._recordingEventSignaling = null;
this.serverRecordingSession = null;
this.serverRecordingPeer = null;
this.meetingRecorder = new MeetingRecorder();
}
subscribe(callback) {
this.listeners.push(callback);
@@ -55,11 +77,11 @@ class CallStateManager {
}
if (settings.resolution) {
this._savedResolution = settings.resolution;
console.log(`已恢复分辨率设置: ${settings.resolution.width}x${settings.resolution.height}`);
logger.debug(`已恢复分辨率设置: ${settings.resolution.width}x${settings.resolution.height}`);
}
}
catch (error) {
console.error('Error loading user settings:', error);
logger.error('Error loading user settings:', error);
}
}
}
@@ -69,9 +91,9 @@ class CallStateManager {
}
async getLocalStream() {
try {
console.log('Requesting camera permission...');
logger.debug('Requesting camera permission...');
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
console.error('getUserMedia is not supported');
logger.error('getUserMedia is not supported');
throw new Error('getUserMedia is not supported');
}
const videoConstraints = buildVideoConstraints(this._savedResolution);
@@ -79,13 +101,13 @@ class CallStateManager {
video: videoConstraints,
audio: AUDIO_CONFIG
});
console.log('Stream obtained successfully:', stream);
console.log('Video tracks:', stream.getVideoTracks());
console.log('Audio tracks:', stream.getAudioTracks());
logger.debug('Stream obtained successfully:', stream);
logger.debug('Video tracks:', stream.getVideoTracks());
logger.debug('Audio tracks:', stream.getAudioTracks());
this.state.localStream = stream;
this.state.session.localUser.mediaState.video = true;
this.state.session.localUser.mediaState.audio = true;
console.log('Local stream stored, notifying UI...');
logger.debug('Local stream stored, notifying UI...');
this.notify({ type: 'LOCAL_STREAM_OBTAINED', stream });
this.notify({ type: 'LOCAL_MEDIA_CHANGE', mediaType: 'video', value: true });
this.notify({ type: 'LOCAL_MEDIA_CHANGE', mediaType: 'audio', value: true });
@@ -93,7 +115,7 @@ class CallStateManager {
this.startActivityDetection(this.state.localStream, { isLocal: true });
}
catch (error) {
console.error('Error getting local stream:', error);
logger.error('Error getting local stream:', error);
this.state.session.localUser.mediaState.video = false;
this.state.session.localUser.mediaState.audio = false;
this.notify({ type: 'LOCAL_MEDIA_CHANGE', mediaType: 'video', value: false });
@@ -104,9 +126,188 @@ class CallStateManager {
await this._updateLocalMediaRefactored(mediaType, value);
return;
}
async toggleRecording() {
const isRecording = this.state.session.localUser.mediaState.recording || false;
if (this.useWebSocket && this.connectionId) {
return isRecording ? this.stopServerRecording() : this.startServerRecording();
}
if (isRecording) {
return this.stopRecording();
}
return this.startRecording();
}
async startServerRecording() {
if (this.state.session.status !== 'ongoing') {
throw new Error('会议连接成功后才能开始录制');
}
const response = await fetch('/api/recording-sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
connectionId: this.connectionId,
layout: 'grid',
format: 'webm'
})
});
const responseBody = await response.json().catch(() => ({}));
if (!response.ok || responseBody.success === false) {
throw new Error(responseBody.message || '服务端录制启动失败');
}
this.serverRecordingSession = responseBody.session;
this._setRecordingMediaState(true);
return {
recording: true,
message: '服务端录制已开始'
};
}
async stopServerRecording() {
const recordingId = this.serverRecordingSession?.id;
if (!recordingId) {
this._setRecordingMediaState(false);
return {
recording: false,
message: '服务端录制已停止'
};
}
const response = await fetch(`/api/recording-sessions/${encodeURIComponent(recordingId)}`, {
method: 'DELETE'
});
const responseBody = await response.json().catch(() => ({}));
if (!response.ok || responseBody.success === false) {
throw new Error(responseBody.message || '服务端录制停止失败');
}
this.serverRecordingSession = responseBody.session;
this._setRecordingMediaState(false);
return {
recording: false,
message: '服务端录制已停止'
};
}
_setRecordingMediaState(value) {
this.state.session.localUser.mediaState.recording = value;
this._notifyLocalMediaChange('recording', value);
this.emitMediaStateChange();
this._notifyUserListUpdate();
}
async startRecording() {
if (this.state.session.status !== 'ongoing') {
throw new Error('会议连接成功后才能开始录制');
}
await this.meetingRecorder.start({
localStream: this.state.localStream,
remoteStream: this.state.remoteStream,
remoteStreams: this.state.remoteStreams,
connectionId: this.connectionId
});
await this._updateLocalMediaRefactored('recording', true);
return {
recording: true,
message: '开始录制会议'
};
}
async stopRecording() {
const result = await this.meetingRecorder.stop();
await this._updateLocalMediaRefactored('recording', false);
if (!result) {
return {
recording: false,
message: '停止录制会议'
};
}
try {
const uploadResult = await this.uploadRecording(result);
return {
recording: false,
message: uploadResult?.url ? `录制已上传到服务器:${uploadResult.url}` : `录制已上传:${result.filename}`
};
}
catch (error) {
logger.error('Recording upload failed:', error);
this.meetingRecorder.download(result.blob, result.filename);
return {
recording: false,
message: `上传失败,已下载到本地:${result.filename}`
};
}
}
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', {
method: 'POST',
body: formData
});
const responseBody = await response.json().catch(() => ({}));
if (!response.ok || responseBody.success === false) {
throw new Error(responseBody.message || 'Recording upload failed');
}
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();
this._refreshServerRecordingPeer();
this._notifyUserListUpdate();
return;
}
@@ -119,6 +320,7 @@ class CallStateManager {
if (mediaType === 'audio') {
this._setLocalAudioTrackEnabled(value);
}
this._refreshServerRecordingPeer();
this._notifyUserListUpdate();
}
async _enableLocalVideo() {
@@ -133,7 +335,7 @@ class CallStateManager {
this.startActivityDetection(this.state.localStream, { isLocal: true });
}
catch (error) {
console.error('Error reopening video:', error);
logger.error('Error reopening video:', error);
this.state.session.localUser.mediaState.video = false;
this._notifyLocalMediaChange('video', false);
}
@@ -162,7 +364,7 @@ class CallStateManager {
if (!this.renderstreaming) {
return;
}
console.log('Updating video track in WebRTC connection');
logger.debug('Updating video track in WebRTC connection');
if (this.role === 'host') {
const participantIds = Object.keys(this.state.remoteStreams);
for (const participantId of participantIds) {
@@ -190,12 +392,12 @@ class CallStateManager {
for (const transceiver of videoTransceivers) {
try {
await transceiver.sender.replaceTrack(newVideoTrack);
console.log(participantId
logger.debug(participantId
? `Replaced video track for participant ${participantId}`
: 'Successfully replaced video track');
}
catch (error) {
console.error(participantId
logger.error(participantId
? `Error replacing video track for ${participantId}:`
: 'Error replacing video track:', error);
}
@@ -205,14 +407,14 @@ class CallStateManager {
try {
if (participantId) {
this.renderstreaming.addTransceiver(newVideoTrack, { direction: 'sendonly' }, participantId);
console.log(`Added new video transceiver for participant ${participantId}`);
logger.debug(`Added new video transceiver for participant ${participantId}`);
return;
}
this.renderstreaming.addTransceiver(newVideoTrack, { direction: 'sendonly' });
console.log('Added new video transceiver');
logger.debug('Added new video transceiver');
}
catch (error) {
console.error(participantId
logger.error(participantId
? `Error adding video transceiver for ${participantId}:`
: 'Error adding video transceiver:', error);
}
@@ -262,10 +464,10 @@ class CallStateManager {
this._signaling = signaling;
this._inviteEventSignaling = bindInviteSocketEvents(this._signaling, this.socketEventHandlers, this._inviteEventSignaling);
if (reused) {
console.log('Signaling already connected, reusing existing instance');
logger.debug('Signaling already connected, reusing existing instance');
return this._signaling;
}
console.log('Signaling connected (WebSocket only, no room yet)');
logger.debug('Signaling connected (WebSocket only, no room yet)');
return this._signaling;
}
getActiveSignaling() {
@@ -295,7 +497,7 @@ class CallStateManager {
this.state.session.status = 'connecting';
this.notify({ type: 'CALL_STATUS_CHANGE', status: 'connecting' });
if (!this.state.localStream) {
console.log('Local stream not available, waiting for initialization...');
logger.debug('Local stream not available, waiting for initialization...');
await new Promise((resolve) => {
const checkStream = () => {
if (this.state.localStream) {
@@ -319,8 +521,10 @@ class CallStateManager {
await this._startConnection(connectionId);
}
_registerCallbacks() {
this._ensureServerRecordingPeer();
this._bindRecordingSignalHandlers();
this.renderstreaming.onNewPeer = (participantId) => {
console.log(`New peer created for ${participantId}, adding local tracks`);
logger.debug(`New peer created for ${participantId}, adding local tracks`);
if (this.state.localStream) {
const tracks = this.state.localStream.getTracks();
for (const track of tracks) {
@@ -337,7 +541,7 @@ class CallStateManager {
if (data.participantId) {
this.selfParticipantId = data.participantId;
}
console.log(`Connected as ${this.role}, participantId: ${this.selfParticipantId}`);
logger.debug(`Connected as ${this.role}, participantId: ${this.selfParticipantId}`);
}
this.state.session.status = 'ongoing';
this.notify({ type: 'CALL_STATUS_CHANGE', status: 'ongoing' });
@@ -349,7 +553,7 @@ class CallStateManager {
}
this.state.session.localUser.mediaState.audio = false;
this.notify({ type: 'LOCAL_MEDIA_CHANGE', mediaType: 'audio', value: false });
console.log('Participant joined with audio muted by default');
logger.debug('Participant joined with audio muted by default');
}
this.sendMessage('user-info', {
id: this.state.session.localUser.id,
@@ -361,16 +565,16 @@ class CallStateManager {
this.showStatsMessage();
}
else {
console.error('Local stream is not available');
logger.error('Local stream is not available');
showNotification('本地视频流不可用', 'error');
}
};
this.renderstreaming.onDisconnect = () => {
console.log('Received disconnect from server, host left or room closed');
logger.debug('Received disconnect from server, host left or room closed');
this.hangUp();
};
this.renderstreaming.onGotAnswer = (connectionId) => {
console.log('SDP Answer received, resetting encoding parameters for connectionId:', connectionId);
logger.debug('SDP Answer received, resetting encoding parameters for connectionId:', connectionId);
if (this.role === 'host') {
const allParticipantIds = Object.keys(this.state.remoteStreams || {});
for (const pid of allParticipantIds) {
@@ -382,13 +586,13 @@ class CallStateManager {
}
};
this.renderstreaming.onParticipantJoined = (participantId) => {
console.log(`Participant joined: ${participantId}`);
logger.debug(`Participant joined: ${participantId}`);
this._upsertParticipant(participantId);
this._notifyParticipantsUpdate();
this.broadcastParticipantsList();
};
this.renderstreaming.onParticipantLeft = (participantId) => {
console.log(`Participant left: ${participantId}, room still active`);
logger.debug(`Participant left: ${participantId}, room still active`);
this.updateRemoteUserStatus('offline');
this.updateRemoteUserNetworkQuality('no_signal');
showNotification('对方已离开通话', 'warning');
@@ -412,6 +616,46 @@ class CallStateManager {
this._handleRenderStreamingMessage(data);
};
}
_ensureServerRecordingPeer() {
if (this.serverRecordingPeer) {
return this.serverRecordingPeer;
}
this.serverRecordingPeer = new ServerRecordingPeer({
rtcConfiguration: getRTCConfiguration(),
getLocalStream: () => this.state.localStream,
getSignaling: () => this.getActiveSignaling(),
getConnectionId: () => this.connectionId,
getParticipantId: () => this.selfParticipantId || (this.role === 'host' ? 'host' : '')
});
return this.serverRecordingPeer;
}
_bindRecordingSignalHandlers() {
const signaling = this.renderstreaming?._signaling;
if (!signaling || signaling === this._recordingEventSignaling || typeof signaling.addEventListener !== 'function') {
return;
}
signaling.addEventListener('recording-started', (event) => {
this._handleRecordingStarted(event.detail);
});
signaling.addEventListener('recording-peer-request', (event) => {
this._handleRecordingPeerRequest(event.detail);
});
signaling.addEventListener('recording-stopped', (event) => {
this._handleRecordingStopped(event.detail);
});
signaling.addEventListener('recording-status', (event) => {
this._handleRecordingStatus(event.detail);
});
signaling.addEventListener('recording-answer', (event) => {
this._handleRecordingAnswer(event.detail);
});
signaling.addEventListener('recording-candidate', (event) => {
this._handleRecordingCandidate(event.detail);
});
this._recordingEventSignaling = signaling;
}
async _startConnection(connectionId) {
await this.renderstreaming.start();
await this.renderstreaming.createConnection(connectionId);
@@ -420,22 +664,33 @@ class CallStateManager {
this.startActivityDetection(this.state.remoteStream, { isLocal: false });
}
async hangUp() {
if (this.meetingRecorder?.isRecording()) {
try {
await this.stopRecording();
}
catch (error) {
logger.error('Error stopping recording before hangUp:', error);
}
}
this.clearStatsMessage();
this.stopNetworkQualityDetection();
if (this.serverRecordingPeer) {
this.serverRecordingPeer.stop();
}
if (this.durationInterval) {
clearInterval(this.durationInterval);
this.durationInterval = null;
}
this.durationSynced = false;
const isHost = this.role === 'host';
console.log(`Disconnect peer on ${this.connectionId}. Role: ${this.role}`);
logger.debug(`Disconnect peer on ${this.connectionId}. Role: ${this.role}`);
if (this.renderstreaming) {
try {
await this.renderstreaming.deleteConnection();
await this.renderstreaming.stop();
}
catch (error) {
console.error('Error during hangUp:', error);
logger.error('Error during hangUp:', error);
}
this.renderstreaming = null;
}
@@ -463,7 +718,7 @@ class CallStateManager {
const isHost = this.role === 'host';
const targetStream = this._getOrCreateRemoteStream(trackParticipantId, isHost);
this._replaceTrackOfSameKind(targetStream, data.track);
console.log('Added new track:', data.track.kind, 'for participant:', trackParticipantId);
logger.debug('Added new track:', data.track.kind, 'for participant:', trackParticipantId);
if (isHost && !this.state.participants[trackParticipantId]) {
this._upsertParticipant(trackParticipantId);
this._notifyParticipantsUpdate();
@@ -500,7 +755,7 @@ class CallStateManager {
const existingTracks = targetStream.getTracks().filter(existingTrack => existingTrack.kind === track.kind);
existingTracks.forEach(existingTrack => {
targetStream.removeTrack(existingTrack);
console.log('Removed old track:', existingTrack.kind);
logger.debug('Removed old track:', existingTrack.kind);
});
targetStream.addTrack(track);
}
@@ -512,13 +767,13 @@ class CallStateManager {
connectionId: trackParticipantId,
isHost
});
console.log('Notified UI about remote stream update');
logger.debug('Notified UI about remote stream update');
};
if (trackKind === 'audio' && targetStream.getVideoTracks().length === 0) {
console.log('Audio track arrived first, delaying stream notification for video track...');
logger.debug('Audio track arrived first, delaying stream notification for video track...');
setTimeout(() => {
const nowHasVideo = targetStream.getVideoTracks().length > 0;
console.log(`After delay, stream has video: ${nowHasVideo}`);
logger.debug(`After delay, stream has video: ${nowHasVideo}`);
notifyStreamUpdate();
}, 200);
return;
@@ -526,7 +781,7 @@ class CallStateManager {
notifyStreamUpdate();
}
_handleRenderStreamingMessage(data) {
console.log('收到信令消息:', data);
logger.debug('收到信令消息:', data);
switch (data.type) {
case 'chat-message':
this._handleChatMessage(data);
@@ -544,6 +799,116 @@ class CallStateManager {
break;
}
}
_isCurrentRecordingEvent(data) {
return data && (!data.connectionId || data.connectionId === this.connectionId);
}
_handleRecordingStarted(data) {
if (!this._isCurrentRecordingEvent(data)) {
return;
}
this.serverRecordingSession = {
id: data.recordingId,
connectionId: data.connectionId,
status: data.status,
layout: data.layout,
format: data.format,
startedAt: data.startedAt
};
this._setRecordingMediaState(true);
showNotification('服务端录制已开始', 'success');
}
_handleRecordingStopped(data) {
if (!this._isCurrentRecordingEvent(data)) {
return;
}
if (this.serverRecordingSession && this.serverRecordingSession.id === data.recordingId) {
this.serverRecordingSession = {
...this.serverRecordingSession,
status: data.status,
stoppedAt: data.stoppedAt
};
}
if (this.serverRecordingPeer) {
this.serverRecordingPeer.stop(data.recordingId);
}
this._setRecordingMediaState(false);
showNotification('服务端录制已停止', 'success');
}
async _handleRecordingPeerRequest(data) {
if (!this._isCurrentRecordingEvent(data)) {
return;
}
logger.debug('收到服务端录制媒体请求:', data);
this.notify({
type: 'RECORDING_PEER_REQUEST',
recordingId: data.recordingId,
mediaMode: data.mediaMode
});
try {
await this._ensureServerRecordingPeer().start(data);
}
catch (error) {
logger.error('服务端录制 PeerConnection 创建失败:', error);
showNotification('服务端录制媒体连接失败', 'error');
}
}
_isServerRecordingActive() {
return this.useWebSocket
&& this.serverRecordingSession
&& this.serverRecordingSession.status === 'recording';
}
_refreshServerRecordingPeer() {
if (!this._isServerRecordingActive() || !this.serverRecordingPeer) {
return;
}
this.serverRecordingPeer.start({
recordingId: this.serverRecordingSession.id,
connectionId: this.connectionId,
mediaMode: 'webrtc-sendonly'
}).catch((error) => {
logger.error('服务端录制媒体重协商失败:', error);
});
}
_handleRecordingStatus(data) {
if (!this._isCurrentRecordingEvent(data)) {
return;
}
logger.debug('收到服务端录制状态:', data);
this.notify({
type: 'RECORDING_STATUS',
status: data.status,
recordingId: data.recordingId
});
}
async _handleRecordingAnswer(data) {
if (!this._isCurrentRecordingEvent(data) || !this.serverRecordingPeer) {
return;
}
try {
await this.serverRecordingPeer.applyAnswer(data);
}
catch (error) {
logger.error('服务端录制 answer 处理失败:', error);
}
}
async _handleRecordingCandidate(data) {
if (!this._isCurrentRecordingEvent(data) || !this.serverRecordingPeer) {
return;
}
try {
await this.serverRecordingPeer.addIceCandidate(data);
}
catch (error) {
logger.error('服务端录制 candidate 处理失败:', error);
}
}
_handleChatMessage(data) {
const chatPayload = data.data || data.message;
if (!chatPayload) {
@@ -578,34 +943,39 @@ class CallStateManager {
}
}
_handleMediaStateChangedMessage(data) {
console.log('收到媒体状态更新:', data.data, 'from participant:', data.participantId);
logger.debug('收到媒体状态更新:', data.data, 'from participant:', data.participantId);
if (this.role === 'host') {
if (data.participantId && this.state.participants[data.participantId]) {
this._upsertParticipant(data.participantId, {
mediaState: data.data
});
const participantChanged = this._updateParticipantMediaStateIfChanged(data.participantId, data.data);
const remoteChanged = this._updateRemoteMediaIfChanged(data.data, data.participantId);
if (participantChanged) {
this._notifyParticipantsUpdate();
this.broadcastParticipantsList();
}
if (!participantChanged && !remoteChanged) {
logger.debug('媒体状态未变化,跳过更新:', data.participantId);
}
this.updateRemoteMedia(data.data, data.participantId);
this._notifyParticipantsUpdate();
this.broadcastParticipantsList();
return;
}
if (data.participantId && data.participantId !== this.selfParticipantId && this.state.participants[data.participantId]) {
this._upsertParticipant(data.participantId, {
mediaState: data.data
});
this._notifyParticipantsUpdate();
if (this._updateParticipantMediaStateIfChanged(data.participantId, data.data)) {
this._notifyParticipantsUpdate();
} else {
logger.debug('媒体状态未变化,跳过参与者更新:', data.participantId);
}
return;
}
if (data.participantId === this.selfParticipantId) {
return;
}
console.log('Received media-state-changed from Host, updating remoteUser:', data.data);
this.updateRemoteMedia(data.data, data.participantId);
this._notifyParticipantsUpdate();
logger.debug('Received media-state-changed from Host, updating remoteUser:', data.data);
if (this._updateRemoteMediaIfChanged(data.data, data.participantId)) {
this._notifyParticipantsUpdate();
} else {
logger.debug('媒体状态未变化,跳过远端用户更新:', data.participantId);
}
}
_handleUserInfoMessage(data) {
console.log('收到用户信息:', data.data, 'from participant:', data.participantId);
logger.debug('收到用户信息:', data.data, 'from participant:', data.participantId);
if (!data.data) {
return;
}
@@ -629,7 +999,7 @@ class CallStateManager {
if (this.role === 'host' || !data.data) {
return;
}
console.log('收到成员同步列表:', data.data);
logger.debug('收到成员同步列表:', data.data);
this.state.participants = omitParticipant(data.data, this.selfParticipantId);
this._notifyParticipantsUpdate();
this._syncCallDuration(data.callDuration);
@@ -646,7 +1016,7 @@ class CallStateManager {
this.durationSynced = true;
this._startDurationTimer();
this.notify({ type: 'DURATION_UPDATE', duration: this.state.session.duration });
console.log(`Call duration synced: ${callDuration} seconds`);
logger.debug(`Call duration synced: ${callDuration} seconds`);
}
_startDurationTimer() {
if (this.durationInterval) {
@@ -694,6 +1064,25 @@ class CallStateManager {
_upsertParticipant(participantId, patch = {}) {
return upsertParticipant(this.state.participants, participantId, patch);
}
_updateParticipantMediaStateIfChanged(participantId, mediaState) {
if (!participantId || !this.state.participants[participantId]) {
return false;
}
if (!hasMediaStateChanged(this.state.participants[participantId].mediaState, mediaState)) {
return false;
}
this._upsertParticipant(participantId, {
mediaState
});
return true;
}
_updateRemoteMediaIfChanged(mediaState, participantId) {
if (!hasMediaStateChanged(this.state.session.remoteUser.mediaState, mediaState)) {
return false;
}
this.updateRemoteMedia(mediaState, participantId);
return true;
}
_removeParticipant(participantId) {
return removeParticipant(this.state.participants, participantId);
}
@@ -706,7 +1095,7 @@ class CallStateManager {
data: memberList,
callDuration: this.state.session.duration
});
console.log('Broadcast participants list:', Object.keys(memberList));
logger.debug('Broadcast participants list:', Object.keys(memberList));
}
setCodecPreferences(participantId) {
const capabilities = RTCRtpSender.getCapabilities('video');
@@ -744,10 +1133,10 @@ class CallStateManager {
t.setCodecPreferences(selectedCodecs);
}
catch (e) {
console.error('Error setting codec preferences:', e);
logger.error('Error setting codec preferences:', e);
}
});
console.log(`Codec preferences set: ${selectedCodecs.map(c => c.mimeType).join(' > ')}`);
logger.debug(`Codec preferences set: ${selectedCodecs.map(c => c.mimeType).join(' > ')}`);
}
}
}
@@ -777,10 +1166,10 @@ class CallStateManager {
params.degradationPreference = 'maintain-resolution';
}
sender.setParameters(params);
console.log(`Set video encoding: maxBitrate=${maxBitrate / 1000000}Mbps, scaleResolutionDownBy=1.0, xGoogleMinBitrate=${Math.floor(maxBitrate * 0.5)}${participantId ? ` for ${participantId}` : ''}`);
logger.debug(`Set video encoding: maxBitrate=${maxBitrate / 1000000}Mbps, scaleResolutionDownBy=1.0, xGoogleMinBitrate=${Math.floor(maxBitrate * 0.5)}${participantId ? ` for ${participantId}` : ''}`);
}
catch (error) {
console.error('Error setting video encoding parameters:', error);
logger.error('Error setting video encoding parameters:', error);
}
}
}
@@ -802,7 +1191,7 @@ class CallStateManager {
height: { ideal: height, max: height },
frameRate: { ideal: 30, max: 30 }
});
console.log(`分辨率已切换为 ${width}x${height}`);
logger.debug(`分辨率已切换为 ${width}x${height}`);
const maxBitrate = getTargetResolutionBitrate(height);
this._applyMaxBitrate(maxBitrate);
const userSettings = JSON.parse(localStorage.getItem('userSettings') || '{}');
@@ -812,7 +1201,7 @@ class CallStateManager {
showNotification('已切换为 ' + label, 'success');
}
catch (error) {
console.error('切换分辨率失败:', error);
logger.error('切换分辨率失败:', error);
showNotification('切换分辨率失败,摄像头可能不支持该分辨率', 'error');
}
}
@@ -835,10 +1224,10 @@ class CallStateManager {
}
params.encodings[0].maxBitrate = maxBitrate;
sender.setParameters(params);
console.log(`Updated maxBitrate to ${maxBitrate} for ${pid || 'self'}`);
logger.debug(`Updated maxBitrate to ${maxBitrate} for ${pid || 'self'}`);
}
catch (error) {
console.error('Error updating maxBitrate:', error);
logger.error('Error updating maxBitrate:', error);
}
}
}
@@ -865,7 +1254,7 @@ class CallStateManager {
this.updateRemoteMedia({ isSpeaking });
}
async endCall() {
console.log(`endCall called. Role: ${this.role}`);
logger.debug(`endCall called. Role: ${this.role}`);
await this.hangUp();
}
async joinCall(connectionId) {
@@ -898,7 +1287,7 @@ class CallStateManager {
}
}
catch (error) {
console.error('Error detecting network quality:', error);
logger.error('Error detecting network quality:', error);
}
}
startActivityDetection(stream, { isLocal = false } = {}) {
@@ -936,10 +1325,10 @@ class CallStateManager {
}
};
detectActivity();
console.log(`${isLocal ? 'Local' : 'Remote'} activity detection started`);
logger.debug(`${isLocal ? 'Local' : 'Remote'} activity detection started`);
}
catch (error) {
console.error(`Error starting ${isLocal ? 'local' : 'remote'} activity detection:`, error);
logger.error(`Error starting ${isLocal ? 'local' : 'remote'} activity detection:`, error);
}
}
startNetworkQualityDetection() {
@@ -958,7 +1347,7 @@ class CallStateManager {
userId: this.state.session.localUser.id,
...this.state.session.localUser.mediaState
};
console.log('[WebSocket Emit] media-state-changed:', payload);
logger.debug('[WebSocket Emit] media-state-changed:', payload);
if (this.renderstreaming) {
this.renderstreaming.sendMessage({
type: 'media-state-changed',
@@ -967,7 +1356,7 @@ class CallStateManager {
}
}
async showStatsMessage() {
console.log('Showing stats message');
logger.debug('Showing stats message');
await this.detectNetworkQuality();
this.statsInterval = setInterval(async () => {
if (!this.renderstreaming) {
@@ -980,19 +1369,19 @@ class CallStateManager {
}
const statsSummary = summarizeInboundStats(stats);
const statsLog = buildStatsLogPayload(this.state.session.remoteUser.networkQuality, statsSummary);
console.log('=== WebRTC Statistics ===');
console.log(`Network Quality: ${statsLog.networkQuality}`);
console.log('Video Stats:', statsLog.video);
console.log('Audio Stats:', statsLog.audio);
console.log('========================');
logger.debug('=== WebRTC Statistics ===');
logger.debug(`Network Quality: ${statsLog.networkQuality}`);
logger.debug('Video Stats:', statsLog.video);
logger.debug('Audio Stats:', statsLog.audio);
logger.debug('========================');
}
catch (error) {
console.error('Error showing stats message:', error);
logger.error('Error showing stats message:', error);
}
}, 5000);
}
clearStatsMessage() {
console.log('Clearing stats message');
logger.debug('Clearing stats message');
if (this.statsInterval) {
clearInterval(this.statsInterval);
this.statsInterval = null;

View File

@@ -1,9 +1,12 @@
import { createLogger } from '../shared/logger.js';
const logger = createLogger('legacy-connect');
/**
* 连接界面逻辑
* 处理初始连接、创建通话和加入通话的功能
*/
import { showNotification, randomMeetingId } from '../utils.js';
import { showNotification, randomMeetingId } from '../shared/utils.js';
const MAX_AVATAR_SIZE = 2 * 1024 * 1024; // 2MB
@@ -17,7 +20,7 @@ function joinCall() {
localStorage.setItem('connectionId', connectionId);
// 跳转到通话界面
window.location.href = '../index.html';
window.location.href = '/';
} else {
showNotification('请输入连接ID', 'error');
}
@@ -36,7 +39,7 @@ function createCall() {
localStorage.setItem('connectionId', connectionId);
// 跳转到通话界面
window.location.href = '../index.html';
window.location.href = '/';
}
@@ -51,7 +54,7 @@ async function getAllConnectionIds() {
const data = await response.json();
displayConnectionIds(data.connectionIds);
} catch (error) {
console.error('Error fetching connection IDs:', error);
logger.error('Error fetching connection IDs:', error);
showNotification('获取连接ID失败', 'error');
}
}
@@ -164,7 +167,7 @@ function loadUserSettings() {
document.getElementById('userAvatar').src = avatar;
document.getElementById('avatarPreview').src = avatar;
} catch (error) {
console.error('Error loading user settings:', error);
logger.error('Error loading user settings:', error);
// 加载失败时使用默认头像
const defaultAvatar = '/images/p1.png';
document.getElementById('userAvatar').src = defaultAvatar;
@@ -254,7 +257,7 @@ function handleAvatarUpload(event) {
}
})
.catch(error => {
console.error('Error uploading avatar:', error);
logger.error('Error uploading avatar:', error);
showNotification('头像上传失败,请重试', 'error');
// 上传失败时,使用默认头像

View File

@@ -6,7 +6,7 @@
<title>VideoCall - 重定向</title>
<script>
// 重定向到SPA入口页面index.html
window.location.href = '../index.html';
window.location.href = '/';
</script>
</head>
<body>

View File

@@ -1,255 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', sans-serif;
background: #0f172a;
overflow: hidden;
}
.bg-grid {
background-image:
linear-gradient(rgba(99, 102, 241, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(99, 102, 241, 0.1) 1px, transparent 1px);
background-size: 40px 40px;
}
@keyframes gridMove {
0% { transform: translate(0, 0); }
100% { transform: translate(20px, 20px); }
}
.glass {
background: rgba(30, 41, 59, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.glass-strong {
background: rgba(15, 23, 42, 0.9);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.control-btn {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.control-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
.end-call-pulse {
animation: pulse-red 2s infinite;
}
@keyframes pulse-red {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); }
50% { box-shadow: 0 0 0 15px rgba(239, 68, 68, 0); }
}
.chat-bubble {
animation: messageSlide 0.3s ease-out;
margin-bottom: 12px;
}
@keyframes messageSlide {
from { opacity: 0; transform: translateX(-10px); }
to { opacity: 1; transform: translateX(0); }
}
/* 消息样式 */
.message-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.message-header img {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.message-sender {
font-size: 12px;
font-weight: 500;
}
.message-time {
font-size: 12px;
color: #94a3b8;
}
.message-content {
padding: 8px 16px;
border-radius: 8px;
font-size: 14px;
max-width: 70%;
}
/* 系统消息 */
.message-system .message-sender {
color: #60a5fa;
}
.message-system .message-content {
background-color: rgba(30, 64, 175, 0.3);
color: #ffffff;
}
/* 对方消息 */
.message-other .message-sender {
color: #a5b4fc;
}
.message-other .message-content {
background-color: #1e293b;
color: #ffffff;
border-top-left-radius: 0;
}
/* 自己的消息 */
.message-self {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.message-self .message-header {
flex-direction: row-reverse;
}
.message-self .message-sender {
color: #4ade80;
}
.message-self .message-content {
background-color: #4f46e5;
color: #ffffff;
border-top-right-radius: 0;
}
/* 图片消息样式 */
.message-image-container {
display: flex;
flex-direction: column;
gap: 8px;
}
.message-image {
max-width: 200px;
max-height: 200px;
border-radius: 8px;
object-fit: cover;
}
.message-image-name {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
text-align: center;
}
.message-text {
word-wrap: break-word;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
}
.audio-wave {
display: flex;
align-items: center;
gap: 3px;
height: 20px;
}
.audio-wave span {
width: 3px;
background: #10b981;
border-radius: 2px;
animation: wave 1s ease-in-out infinite;
}
.audio-wave span:nth-child(1) { animation-delay: 0s; height: 40%; }
.audio-wave span:nth-child(2) { animation-delay: 0.1s; height: 70%; }
.audio-wave span:nth-child(3) { animation-delay: 0.2s; height: 100%; }
.audio-wave span:nth-child(4) { animation-delay: 0.3s; height: 60%; }
.audio-wave span:nth-child(5) { animation-delay: 0.4s; height: 30%; }
@keyframes wave {
0%, 100% { transform: scaleY(0.5); }
50% { transform: scaleY(1); }
}
.video-fade-in {
animation: videoFadeIn 0.5s ease-out;
}
@keyframes videoFadeIn {
from { opacity: 0; transform: scale(1.05); }
to { opacity: 1; transform: scale(1); }
}
/* 数据绑定标记 - 开发调试时显示
[data-field]::after {
content: attr(data-field);
position: absolute;
top: -18px;
right: 0;
background: #f59e0b;
color: #000;
font-size: 9px;
padding: 1px 4px;
border-radius: 3px;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
z-index: 1000;
white-space: nowrap;
}
[data-field]:hover::after { opacity: 1; }
[data-field] { position: relative; }*/
/* 分辨率选项样式 */
.resolution-option {
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
transition: all 0.2s;
}
.resolution-option:hover {
color: white;
}
.resolution-option.active {
background: rgba(99, 102, 241, 0.3);
color: white;
}
.resolution-option.active::before {
content: '\f00c';
font-family: 'Font Awesome 6 Free';
font-weight: 900;
font-size: 10px;
margin-right: 6px;
color: #818cf8;
}
/* 更多选项菜单动画 */
#moreOptionsMenu {
animation: menuFadeIn 0.15s ease-out;
}
@keyframes menuFadeIn {
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}

View File

@@ -3,14 +3,14 @@
* 处理通话结束后的操作,如重新连接或返回连接界面
*/
import { showNotification } from '../utils.js';
import { showNotification } from '../shared/utils.js';
// 重新连接
function reconnectCall() {
showNotification('正在重新连接...');
// 跳转到通话界面
window.location.href = '../index.html';
window.location.href = '/';
}
// 离开
@@ -19,7 +19,7 @@ function leaveCall() {
localStorage.removeItem('connectionId');
// 跳转到连接界面
window.location.href = '../connect/connect.html';
window.location.href = '/connect/';
}
// 绑定事件监听器

View File

@@ -6,7 +6,7 @@
<title>VideoCall - 通话结束</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link rel="stylesheet" href="../css/style.css">
<link rel="stylesheet" href="/styles/style.css">
</head>
<body class="h-screen w-screen flex flex-col text-white bg-grid relative">
<!--

View File

@@ -0,0 +1,227 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VideoCall - 录制管理</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link rel="stylesheet" href="/styles/style.css">
</head>
<body class="min-h-screen w-screen text-white bg-grid recordings-page">
<div class="min-h-screen bg-black/70 flex flex-col">
<header class="glass-strong h-16 flex items-center justify-between px-6 border-b border-white/10">
<div class="flex items-center gap-3 min-w-0">
<div class="w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg">
<i class="fas fa-video text-white text-lg"></i>
</div>
<div class="min-w-0">
<h1 class="font-bold text-lg tracking-tight">录制管理后台</h1>
<div class="flex items-center gap-3 text-xs text-gray-400">
<span class="w-2 h-2 bg-green-500 rounded-full"></span>
<span id="recordingRootText" class="truncate max-w-[60vw]">recordings</span>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<a href="/" class="recordings-icon-btn glass" title="返回视频通话">
<i class="fas fa-phone"></i>
</a>
<button id="refreshBtn" class="recordings-icon-btn glass" title="刷新列表">
<i class="fas fa-rotate-right"></i>
</button>
</div>
</header>
<main class="recordings-shell flex-1 overflow-hidden">
<section class="recordings-toolbar glass">
<div class="recordings-stat">
<span class="recordings-stat-value" id="totalCount">0</span>
<span class="recordings-stat-label">总录制</span>
</div>
<div class="recordings-stat">
<span class="recordings-stat-value" id="meetingCount">0</span>
<span class="recordings-stat-label">会议数</span>
</div>
<div class="recordings-stat">
<span class="recordings-stat-value" id="storageSize">0 MB</span>
<span class="recordings-stat-label">占用空间</span>
</div>
<div class="recordings-search">
<i class="fas fa-magnifying-glass text-gray-500"></i>
<input id="searchInput" type="search" placeholder="搜索会议、文件或用户" autocomplete="off">
</div>
<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">
<aside class="recordings-upload glass">
<div class="flex items-center justify-between mb-5">
<div>
<h2 class="text-base font-semibold">新增录制</h2>
<p class="text-xs text-gray-500 mt-1">上传到 recordings 目录</p>
</div>
<div class="w-10 h-10 rounded-xl bg-indigo-500/20 flex items-center justify-center text-indigo-300">
<i class="fas fa-cloud-arrow-up"></i>
</div>
</div>
<form id="uploadForm" class="space-y-4">
<label class="recordings-dropzone" for="recordingFile">
<input id="recordingFile" type="file" accept="video/mp4,video/webm" class="hidden" required>
<i class="fas fa-file-video text-2xl text-indigo-300"></i>
<span id="fileNameText">选择 MP4 或 WebM 文件</span>
</label>
<div>
<label class="recordings-label" for="uploadMeetingId">会议 ID</label>
<input id="uploadMeetingId" class="recordings-input" type="text" placeholder="例如 665-261-326" required>
</div>
<div>
<label class="recordings-label" for="uploadUserId">用户 ID</label>
<input id="uploadUserId" class="recordings-input" type="text" placeholder="可选">
</div>
<button id="uploadBtn" class="recordings-primary-btn" type="submit">
<i class="fas fa-plus"></i>
<span>上传录制</span>
</button>
</form>
</aside>
<section class="recordings-list glass">
<div class="recordings-list-head">
<div>
<h2 class="text-base font-semibold">录制文件</h2>
<p class="text-xs text-gray-500 mt-1" id="listSummary">等待加载</p>
</div>
<button id="clearSearchBtn" class="recordings-text-btn hidden">
<i class="fas fa-xmark"></i>
<span>清空筛选</span>
</button>
</div>
<div id="loadingState" class="recordings-empty">
<div class="w-10 h-10 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
<span>正在读取 recordings 目录...</span>
</div>
<div id="emptyState" class="recordings-empty hidden">
<i class="fas fa-folder-open text-3xl text-gray-500"></i>
<span>还没有录制文件</span>
</div>
<div id="recordingsTableWrap" class="recordings-table-wrap hidden">
<table class="recordings-table">
<thead>
<tr>
<th>文件</th>
<th>会议</th>
<th>房主</th>
<th>参与者</th>
<th>大小</th>
<th>上传时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="recordingsTableBody"></tbody>
</table>
</div>
</section>
<aside class="recordings-preview glass">
<div class="recordings-preview-video">
<video id="previewVideo" controls playsinline></video>
<div id="previewPlaceholder" class="recordings-preview-placeholder">
<i class="fas fa-play text-2xl"></i>
<span>选择一条录制预览</span>
</div>
</div>
<div class="recordings-preview-meta">
<h2 id="previewTitle">未选择录制</h2>
<div id="previewDetails" class="space-y-3 text-sm text-gray-400">
<p>从左侧列表选择文件后,可播放、下载、编辑或删除。</p>
</div>
</div>
</aside>
</section>
</main>
</div>
<div id="editDialog" class="fixed inset-0 bg-black/70 hidden items-center justify-center z-50">
<form id="editForm" class="recordings-dialog glass">
<div class="flex items-center justify-between mb-5">
<div>
<h2 class="text-lg font-semibold">编辑录制信息</h2>
<p class="text-xs text-gray-500 mt-1" id="editFilenameText"></p>
</div>
<button type="button" id="closeEditBtn" class="recordings-icon-btn" title="关闭">
<i class="fas fa-xmark"></i>
</button>
</div>
<div class="space-y-4">
<div>
<label class="recordings-label" for="editMeetingId">会议 ID</label>
<input id="editMeetingId" class="recordings-input" type="text" required>
</div>
<div>
<label class="recordings-label" for="editOriginalFilename">显示名称</label>
<input id="editOriginalFilename" class="recordings-input" type="text" required>
</div>
<div>
<label class="recordings-label" for="editUserId">房主用户 ID</label>
<input id="editUserId" class="recordings-input" type="text">
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="button" id="cancelEditBtn" class="recordings-secondary-btn">取消</button>
<button type="submit" class="recordings-primary-btn">
<i class="fas fa-floppy-disk"></i>
<span>保存</span>
</button>
</div>
</form>
</div>
<div id="notification" class="recordings-notification glass">
<i class="fas fa-info-circle text-indigo-400"></i>
<span id="notificationText"></span>
</div>
<script type="module" src="/recordings/recordings-admin.js"></script>
</body>
</html>

View File

@@ -0,0 +1,530 @@
const state = {
recordings: [],
filtered: [],
selectedKey: '',
editing: null
};
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'),
recordingFile: document.getElementById('recordingFile'),
fileNameText: document.getElementById('fileNameText'),
uploadMeetingId: document.getElementById('uploadMeetingId'),
uploadUserId: document.getElementById('uploadUserId'),
totalCount: document.getElementById('totalCount'),
meetingCount: document.getElementById('meetingCount'),
storageSize: document.getElementById('storageSize'),
recordingRootText: document.getElementById('recordingRootText'),
listSummary: document.getElementById('listSummary'),
loadingState: document.getElementById('loadingState'),
emptyState: document.getElementById('emptyState'),
recordingsTableWrap: document.getElementById('recordingsTableWrap'),
recordingsTableBody: document.getElementById('recordingsTableBody'),
previewVideo: document.getElementById('previewVideo'),
previewPlaceholder: document.getElementById('previewPlaceholder'),
previewTitle: document.getElementById('previewTitle'),
previewDetails: document.getElementById('previewDetails'),
editDialog: document.getElementById('editDialog'),
editForm: document.getElementById('editForm'),
editFilenameText: document.getElementById('editFilenameText'),
editMeetingId: document.getElementById('editMeetingId'),
editOriginalFilename: document.getElementById('editOriginalFilename'),
editUserId: document.getElementById('editUserId'),
closeEditBtn: document.getElementById('closeEditBtn'),
cancelEditBtn: document.getElementById('cancelEditBtn'),
notification: document.getElementById('notification'),
notificationText: document.getElementById('notificationText')
};
const typeFilterLabels = {
all: '全部格式',
mp4: 'MP4',
webm: 'WebM'
};
function recordingKey(recording) {
return `${recording.meetingId}/${recording.filename}`;
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function formatBytes(bytes) {
const value = Number(bytes) || 0;
if (value < 1024) {
return `${value} B`;
}
const units = ['KB', 'MB', 'GB', 'TB'];
let size = value / 1024;
let index = 0;
while (size >= 1024 && index < units.length - 1) {
size /= 1024;
index += 1;
}
return `${size.toFixed(size >= 10 ? 1 : 2)} ${units[index]}`;
}
function formatDate(value) {
if (!value) {
return '-';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '-';
}
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
function getPersonId(person) {
return person?.userId || person?.id || person?.participantId || '';
}
function getPersonName(person) {
return person?.name || person?.displayName || 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);
elements.notification.classList.add('recordings-notification-visible');
window.clearTimeout(showNotification.timer);
showNotification.timer = window.setTimeout(() => {
elements.notification.classList.remove('recordings-notification-visible');
}, 2600);
}
async function requestJson(url, options = {}) {
const response = await fetch(url, options);
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload.success === false) {
throw new Error(payload.message || `请求失败: ${response.status}`);
}
return payload;
}
function setLoading(isLoading) {
elements.loadingState.classList.toggle('hidden', !isLoading);
elements.recordingsTableWrap.classList.toggle('hidden', isLoading || state.filtered.length === 0);
elements.emptyState.classList.toggle('hidden', isLoading || state.filtered.length > 0);
}
async function loadRecordings() {
setLoading(true);
try {
const payload = await requestJson('/api/recordings');
state.recordings = payload.recordings || [];
elements.recordingRootText.textContent = payload.root || 'recordings';
applyFilters();
const selected = state.filtered.find(item => recordingKey(item) === state.selectedKey) || state.filtered[0];
selectRecording(selected || null);
} catch (error) {
state.recordings = [];
applyFilters();
selectRecording(null);
showNotification(error.message, true);
} finally {
setLoading(false);
}
}
function applyFilters() {
const query = elements.searchInput.value.trim().toLowerCase();
const type = elements.typeFilter.value;
state.filtered = state.recordings.filter((recording) => {
const extension = (recording.filename || '').split('.').pop().toLowerCase();
const haystack = [
recording.meetingId,
recording.filename,
recording.originalFilename,
recording.userId,
getPeopleSearchText(recording)
].join(' ').toLowerCase();
return (type === 'all' || extension === type) && (!query || haystack.includes(query));
});
elements.clearSearchBtn.classList.toggle('hidden', !query && type === 'all');
renderSummary();
renderTable();
setLoading(false);
}
function renderSummary() {
const meetings = new Set(state.recordings.map(recording => recording.meetingId));
const totalSize = state.recordings.reduce((sum, recording) => sum + (Number(recording.size) || 0), 0);
elements.totalCount.textContent = state.recordings.length;
elements.meetingCount.textContent = meetings.size;
elements.storageSize.textContent = formatBytes(totalSize);
elements.listSummary.textContent = `当前显示 ${state.filtered.length} 条,共 ${state.recordings.length}`;
}
function renderTable() {
elements.recordingsTableBody.innerHTML = state.filtered.map((recording) => {
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)}">
<td>
<button class="recordings-file-cell" data-action="select" data-key="${escapeHtml(key)}">
<span class="recordings-file-icon">${ext}</span>
<span class="min-w-0">
<span class="recordings-file-name">${escapeHtml(recording.originalFilename || recording.filename)}</span>
<span class="recordings-file-sub">${escapeHtml(recording.filename)}</span>
</span>
</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>
<div class="recordings-actions">
<button class="recordings-icon-btn" data-action="preview" data-key="${escapeHtml(key)}" title="预览">
<i class="fas fa-play"></i>
</button>
<a class="recordings-icon-btn" href="${escapeHtml(recording.downloadUrl)}" title="下载">
<i class="fas fa-download"></i>
</a>
<button class="recordings-icon-btn" data-action="edit" data-key="${escapeHtml(key)}" title="编辑">
<i class="fas fa-pen"></i>
</button>
<button class="recordings-icon-btn recordings-danger" data-action="delete" data-key="${escapeHtml(key)}" title="删除">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
`;
}).join('');
}
function findRecording(key) {
return state.recordings.find(recording => recordingKey(recording) === key);
}
function selectRecording(recording) {
if (!recording) {
state.selectedKey = '';
elements.previewVideo.removeAttribute('src');
elements.previewVideo.load();
elements.previewPlaceholder.classList.remove('hidden');
elements.previewTitle.textContent = '未选择录制';
elements.previewDetails.innerHTML = '<p>从左侧列表选择文件后,可播放、下载、编辑或删除。</p>';
renderTable();
return;
}
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;
elements.previewDetails.innerHTML = `
<div class="recordings-detail-row"><span>会议 ID</span><strong>${escapeHtml(recording.meetingId)}</strong></div>
<div class="recordings-detail-row"><span>文件名</span><strong>${escapeHtml(recording.filename)}</strong></div>
<div class="recordings-detail-row"><span>格式</span><strong>${escapeHtml(recording.mimetype)}</strong></div>
<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>
<span>下载</span>
</a>
<button class="recordings-secondary-btn" type="button" data-preview-action="edit">编辑</button>
</div>
`;
renderTable();
}
function openEdit(recording) {
if (!recording) {
return;
}
state.editing = recording;
elements.editFilenameText.textContent = recording.filename;
elements.editMeetingId.value = recording.meetingId || '';
elements.editOriginalFilename.value = recording.originalFilename || recording.filename || '';
elements.editUserId.value = recording.userId || '';
elements.editDialog.classList.remove('hidden');
elements.editDialog.classList.add('flex');
elements.editMeetingId.focus();
}
function closeEdit() {
state.editing = null;
elements.editDialog.classList.add('hidden');
elements.editDialog.classList.remove('flex');
}
async function handleUpload(event) {
event.preventDefault();
const file = elements.recordingFile.files[0];
if (!file) {
showNotification('请选择录制文件', true);
return;
}
const formData = new FormData();
formData.append('recording', file, file.name);
formData.append('filename', file.name);
formData.append('meetingId', elements.uploadMeetingId.value.trim());
formData.append('userId', elements.uploadUserId.value.trim());
elements.uploadBtn.disabled = true;
try {
await requestJson('/api/recordings', {
method: 'POST',
body: formData
});
elements.uploadForm.reset();
elements.fileNameText.textContent = '选择 MP4 或 WebM 文件';
showNotification('录制已上传');
await loadRecordings();
} catch (error) {
showNotification(error.message, true);
} finally {
elements.uploadBtn.disabled = false;
}
}
async function handleEdit(event) {
event.preventDefault();
if (!state.editing) {
return;
}
const recording = state.editing;
try {
const payload = await requestJson(`/api/recordings/${encodeURIComponent(recording.meetingId)}/${encodeURIComponent(recording.filename)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
meetingId: elements.editMeetingId.value.trim(),
originalFilename: elements.editOriginalFilename.value.trim(),
userId: elements.editUserId.value.trim()
})
});
state.selectedKey = recordingKey(payload.recording);
closeEdit();
showNotification('录制信息已更新');
await loadRecordings();
} catch (error) {
showNotification(error.message, true);
}
}
async function deleteRecording(recording) {
if (!recording) {
return;
}
const confirmed = window.confirm(`确定删除录制文件 "${recording.originalFilename || recording.filename}" 吗?`);
if (!confirmed) {
return;
}
try {
await requestJson(`/api/recordings/${encodeURIComponent(recording.meetingId)}/${encodeURIComponent(recording.filename)}`, {
method: 'DELETE'
});
if (state.selectedKey === recordingKey(recording)) {
state.selectedKey = '';
}
showNotification('录制已删除');
await loadRecordings();
} catch (error) {
showNotification(error.message, true);
}
}
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 = '';
setTypeFilter('all');
applyFilters();
});
elements.recordingFile.addEventListener('change', () => {
const file = elements.recordingFile.files[0];
elements.fileNameText.textContent = file ? file.name : '选择 MP4 或 WebM 文件';
});
elements.uploadForm.addEventListener('submit', handleUpload);
elements.editForm.addEventListener('submit', handleEdit);
elements.closeEditBtn.addEventListener('click', closeEdit);
elements.cancelEditBtn.addEventListener('click', closeEdit);
elements.editDialog.addEventListener('click', (event) => {
if (event.target === elements.editDialog) {
closeEdit();
}
});
elements.recordingsTableBody.addEventListener('click', (event) => {
const control = event.target.closest('[data-action]');
if (!control) {
return;
}
const recording = findRecording(control.dataset.key);
const action = control.dataset.action;
if (action === 'select' || action === 'preview') {
selectRecording(recording);
if (action === 'preview') {
elements.previewVideo.play().catch(() => undefined);
}
} else if (action === 'edit') {
openEdit(recording);
} else if (action === 'delete') {
deleteRecording(recording);
}
});
elements.previewDetails.addEventListener('click', (event) => {
const control = event.target.closest('[data-preview-action]');
if (control && control.dataset.previewAction === 'edit') {
openEdit(findRecording(state.selectedKey));
}
});
}
bindEvents();
setTypeFilter(elements.typeFilter.value);
loadRecordings();

View File

@@ -1,5 +1,5 @@
import { Observer, Sender } from "../module/sender.js";
import { InputRemoting } from "../module/inputremoting.js";
import { Observer, Sender } from "/module/core/sender.js";
import { InputRemoting } from "/module/input/inputremoting.js";
export class VideoPlayer {
constructor() {
@@ -210,4 +210,4 @@ export class VideoPlayer {
await new Promise(resolve => setTimeout(resolve, 100));
this.inputRemoting.startSending();
}
}
}

View File

@@ -1,61 +0,0 @@
export function createMessageElement(message, formatTimestamp) {
const messageDiv = document.createElement('div');
let messageClass = 'chat-bubble';
if (message.type === 'system') {
messageClass += ' message-system';
} else if (message.isSelf) {
messageClass += ' message-self';
} else {
messageClass += ' message-other';
}
messageDiv.className = messageClass;
messageDiv.dataset.messageId = message.id;
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>
`;
messageDiv.innerHTML = `
<div class="message-header">
<img src="${message.senderAvatar}" class="message-avatar">
<div>
<span class="message-sender">${message.senderName}</span>
<span class="message-time">${formatTimestamp(message.timestamp)}</span>
</div>
</div>
<div class="message-content">
${contentHTML}
</div>
`;
return messageDiv;
}
export function renderChatMessagesInto(container, messages, formatTimestamp) {
if (!container) return;
container.innerHTML = '';
const startTimeElement = document.createElement('div');
startTimeElement.className = 'text-center text-xs text-gray-500 my-4';
const startTime = messages[0]?.timestamp || new Date().toISOString();
startTimeElement.textContent = `\u901a\u8bdd\u5f00\u59cb ${formatTimestamp(startTime)}`;
container.appendChild(startTimeElement);
messages.forEach(message => {
container.appendChild(createMessageElement(message, formatTimestamp));
});
container.scrollTop = container.scrollHeight;
}

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

@@ -0,0 +1,84 @@
const LOG_LEVELS = {
debug: 10,
info: 20,
warn: 30,
error: 40,
silent: 50
};
const STORAGE_KEY = 'video_socket_log_level';
const DEFAULT_LEVEL = 'warn';
function normalizeLevel(level) {
if (!level) {
return DEFAULT_LEVEL;
}
const normalized = String(level).toLowerCase();
return Object.prototype.hasOwnProperty.call(LOG_LEVELS, normalized)
? normalized
: DEFAULT_LEVEL;
}
function getConfiguredLevel() {
try {
const queryLevel = new URLSearchParams(window.location.search).get('logLevel');
if (queryLevel) {
return normalizeLevel(queryLevel);
}
} catch (_error) {
}
try {
const storageLevel = localStorage.getItem(STORAGE_KEY);
if (storageLevel) {
return normalizeLevel(storageLevel);
}
} catch (_error) {
}
return DEFAULT_LEVEL;
}
function shouldLog(level) {
return LOG_LEVELS[level] >= LOG_LEVELS[getConfiguredLevel()];
}
function getConsoleMethod(level) {
switch (level) {
case 'debug':
return console.debug;
case 'info':
return console.info;
case 'warn':
return console.warn;
case 'error':
return console.error;
default:
return console.log;
}
}
function emit(level, scope, args) {
if (!shouldLog(level)) {
return;
}
const prefix = scope ? `[${scope}]` : '[app]';
getConsoleMethod(level)(prefix, ...args);
}
export function createLogger(scope) {
return {
debug: (...args) => emit('debug', scope, args),
info: (...args) => emit('info', scope, args),
warn: (...args) => emit('warn', scope, args),
error: (...args) => emit('error', scope, args)
};
}
export function setBrowserLogLevel(level) {
const normalized = normalizeLevel(level);
localStorage.setItem(STORAGE_KEY, normalized);
return normalized;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,942 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', sans-serif;
background: #0f172a;
overflow: hidden;
}
.bg-grid {
background-image:
linear-gradient(rgba(99, 102, 241, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(99, 102, 241, 0.1) 1px, transparent 1px);
background-size: 40px 40px;
}
@keyframes gridMove {
0% { transform: translate(0, 0); }
100% { transform: translate(20px, 20px); }
}
.glass {
background: rgba(30, 41, 59, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.glass-strong {
background: rgba(15, 23, 42, 0.9);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.control-btn {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.control-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
.end-call-pulse {
animation: pulse-red 2s infinite;
}
@keyframes pulse-red {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); }
50% { box-shadow: 0 0 0 15px rgba(239, 68, 68, 0); }
}
.chat-bubble {
animation: messageSlide 0.3s ease-out;
margin-bottom: 12px;
}
@keyframes messageSlide {
from { opacity: 0; transform: translateX(-10px); }
to { opacity: 1; transform: translateX(0); }
}
/* 消息样式 */
.message-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.message-header img {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.message-sender {
font-size: 12px;
font-weight: 500;
}
.message-time {
font-size: 12px;
color: #94a3b8;
}
.message-content {
padding: 8px 16px;
border-radius: 8px;
font-size: 14px;
max-width: 70%;
}
/* 系统消息 */
.message-system .message-sender {
color: #60a5fa;
}
.message-system .message-content {
background-color: rgba(30, 64, 175, 0.3);
color: #ffffff;
}
/* 对方消息 */
.message-other .message-sender {
color: #a5b4fc;
}
.message-other .message-content {
background-color: #1e293b;
color: #ffffff;
border-top-left-radius: 0;
}
/* 自己的消息 */
.message-self {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.message-self .message-header {
flex-direction: row-reverse;
}
.message-self .message-sender {
color: #4ade80;
}
.message-self .message-content {
background-color: #4f46e5;
color: #ffffff;
border-top-right-radius: 0;
}
/* 图片消息样式 */
.message-image-container {
display: flex;
flex-direction: column;
gap: 8px;
}
.message-image {
max-width: 200px;
max-height: 200px;
border-radius: 8px;
object-fit: cover;
}
.message-image-name {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
text-align: center;
}
.message-text {
word-wrap: break-word;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
}
.audio-wave {
display: flex;
align-items: center;
gap: 3px;
height: 20px;
}
.audio-wave span {
width: 3px;
background: #10b981;
border-radius: 2px;
animation: wave 1s ease-in-out infinite;
}
.audio-wave span:nth-child(1) { animation-delay: 0s; height: 40%; }
.audio-wave span:nth-child(2) { animation-delay: 0.1s; height: 70%; }
.audio-wave span:nth-child(3) { animation-delay: 0.2s; height: 100%; }
.audio-wave span:nth-child(4) { animation-delay: 0.3s; height: 60%; }
.audio-wave span:nth-child(5) { animation-delay: 0.4s; height: 30%; }
@keyframes wave {
0%, 100% { transform: scaleY(0.5); }
50% { transform: scaleY(1); }
}
.video-fade-in {
animation: videoFadeIn 0.5s ease-out;
}
@keyframes videoFadeIn {
from { opacity: 0; transform: scale(1.05); }
to { opacity: 1; transform: scale(1); }
}
/* 数据绑定标记 - 开发调试时显示
[data-field]::after {
content: attr(data-field);
position: absolute;
top: -18px;
right: 0;
background: #f59e0b;
color: #000;
font-size: 9px;
padding: 1px 4px;
border-radius: 3px;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
z-index: 1000;
white-space: nowrap;
}
[data-field]:hover::after { opacity: 1; }
[data-field] { position: relative; }*/
/* 分辨率选项样式 */
.resolution-option {
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
transition: all 0.2s;
}
.resolution-option:hover {
color: white;
}
.resolution-option.active {
background: rgba(99, 102, 241, 0.3);
color: white;
}
.resolution-option.active::before {
content: '\f00c';
font-family: 'Font Awesome 6 Free';
font-weight: 900;
font-size: 10px;
margin-right: 6px;
color: #818cf8;
}
/* 更多选项菜单动画 */
#moreOptionsMenu {
animation: menuFadeIn 0.15s ease-out;
}
@keyframes menuFadeIn {
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
.recordings-page {
overflow: hidden;
}
.recordings-shell {
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.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) 152px;
align-items: center;
gap: 12px;
}
.recordings-stat {
min-width: 112px;
padding: 6px 10px;
}
.recordings-stat-value {
display: block;
font-size: 20px;
line-height: 1.1;
font-weight: 700;
color: #ffffff;
}
.recordings-stat-label {
display: block;
margin-top: 4px;
font-size: 12px;
color: #94a3b8;
}
.recordings-search,
.recordings-input {
height: 42px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(15, 23, 42, 0.58);
color: #ffffff;
}
.recordings-search {
display: flex;
align-items: center;
gap: 10px;
padding: 0 14px;
}
.recordings-search input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: #ffffff;
font-size: 14px;
}
.recordings-search input::placeholder,
.recordings-input::placeholder {
color: #64748b;
}
.recordings-format-filter {
position: relative;
z-index: 20;
min-width: 152px;
}
.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 {
min-height: 0;
flex: 1;
display: grid;
grid-template-columns: 280px minmax(420px, 1fr) 360px;
gap: 16px;
overflow: hidden;
}
.recordings-upload,
.recordings-list,
.recordings-preview {
min-height: 0;
border-radius: 16px;
}
.recordings-upload {
padding: 18px;
}
.recordings-list {
padding: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.recordings-list-head {
min-height: 72px;
padding: 18px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.recordings-preview {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
overflow: hidden;
}
.recordings-dropzone {
min-height: 132px;
border-radius: 14px;
border: 1px dashed rgba(129, 140, 248, 0.55);
background: rgba(79, 70, 229, 0.08);
color: #c7d2fe;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
text-align: center;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.recordings-dropzone:hover {
background: rgba(79, 70, 229, 0.16);
border-color: rgba(165, 180, 252, 0.8);
}
.recordings-label {
display: block;
margin-bottom: 8px;
font-size: 12px;
color: #94a3b8;
}
.recordings-input {
width: 100%;
padding: 0 12px;
outline: 0;
}
.recordings-input: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);
}
.recordings-primary-btn,
.recordings-secondary-btn,
.recordings-text-btn,
.recordings-icon-btn {
border: 0;
outline: 0;
cursor: pointer;
transition: transform 0.2s, background 0.2s, color 0.2s, border-color 0.2s;
}
.recordings-primary-btn,
.recordings-secondary-btn {
min-height: 42px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
}
.recordings-primary-btn {
width: 100%;
background: #4f46e5;
color: #ffffff;
}
.recordings-primary-btn:hover {
background: #4338ca;
}
.recordings-primary-btn:disabled {
opacity: 0.55;
cursor: wait;
}
.recordings-secondary-btn {
width: 100%;
background: rgba(255, 255, 255, 0.08);
color: #e2e8f0;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.recordings-secondary-btn:hover {
background: rgba(255, 255, 255, 0.14);
}
.recordings-text-btn {
display: inline-flex;
align-items: center;
gap: 8px;
color: #c7d2fe;
background: transparent;
font-size: 13px;
}
.recordings-icon-btn {
width: 38px;
height: 38px;
border-radius: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #cbd5e1;
background: rgba(255, 255, 255, 0.06);
}
.recordings-icon-btn:hover {
transform: translateY(-1px);
color: #ffffff;
background: rgba(255, 255, 255, 0.12);
}
.recordings-danger {
color: #fca5a5;
}
.recordings-danger:hover {
color: #ffffff;
background: rgba(239, 68, 68, 0.75);
}
.recordings-table-wrap {
flex: 1;
min-height: 0;
max-height: 100%;
overflow-x: auto;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.recordings-table-wrap::-webkit-scrollbar,
.recordings-preview-meta::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.recordings-table-wrap::-webkit-scrollbar-track,
.recordings-preview-meta::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.58);
}
.recordings-table-wrap::-webkit-scrollbar-thumb,
.recordings-preview-meta::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.32);
border-radius: 999px;
}
.recordings-table-wrap::-webkit-scrollbar-thumb:hover,
.recordings-preview-meta::-webkit-scrollbar-thumb:hover {
background: rgba(165, 180, 252, 0.52);
}
.recordings-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.recordings-table th,
.recordings-table td {
padding: 13px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
text-align: left;
vertical-align: middle;
}
.recordings-table th {
position: sticky;
top: 0;
z-index: 1;
background: rgba(15, 23, 42, 0.95);
color: #94a3b8;
font-size: 12px;
font-weight: 600;
}
.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;
}
.recordings-table tbody tr:hover,
.recordings-row-active {
background: rgba(99, 102, 241, 0.14);
}
.recordings-file-cell {
max-width: 100%;
display: flex;
align-items: center;
gap: 10px;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.recordings-file-icon {
flex: 0 0 auto;
width: 46px;
height: 30px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(16, 185, 129, 0.16);
color: #6ee7b7;
font-size: 11px;
font-weight: 700;
}
.recordings-file-name,
.recordings-file-sub {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recordings-file-name {
color: #ffffff;
font-size: 14px;
font-weight: 600;
}
.recordings-file-sub {
margin-top: 3px;
color: #64748b;
font-size: 12px;
}
.recordings-actions {
display: flex;
align-items: center;
gap: 6px;
}
.recordings-empty {
flex: 1;
min-height: 260px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
color: #94a3b8;
}
.recordings-preview-video {
position: relative;
aspect-ratio: 16 / 9;
border-radius: 14px;
overflow: hidden;
background: #020617;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.recordings-preview-video video {
width: 100%;
height: 100%;
object-fit: contain;
}
.recordings-preview-placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: #94a3b8;
background: linear-gradient(135deg, rgba(49, 46, 129, 0.55), rgba(8, 47, 73, 0.45));
}
.recordings-preview-meta {
min-height: 0;
overflow: auto;
}
.recordings-preview-meta h2 {
margin-bottom: 14px;
font-size: 17px;
font-weight: 700;
word-break: break-word;
}
.recordings-detail-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 10px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.recordings-detail-row span {
color: #94a3b8;
}
.recordings-detail-row strong {
color: #e2e8f0;
font-weight: 600;
text-align: right;
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;
gap: 10px;
margin-top: 16px;
}
.recordings-dialog {
width: min(440px, calc(100vw - 32px));
border-radius: 18px;
padding: 22px;
}
.recordings-notification {
position: fixed;
top: 82px;
left: 50%;
transform: translate(-50%, -16px);
z-index: 60;
min-height: 44px;
padding: 0 18px;
border-radius: 999px;
display: flex;
align-items: center;
gap: 10px;
color: #ffffff;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s, transform 0.2s;
}
.recordings-notification-visible {
opacity: 1;
transform: translate(-50%, 0);
}
.recordings-notification-error i {
color: #f87171;
}
@media (max-width: 1180px) {
.recordings-content {
grid-template-columns: 260px minmax(420px, 1fr);
}
.recordings-preview {
grid-column: 1 / -1;
min-height: 360px;
display: grid;
grid-template-columns: minmax(360px, 0.8fr) 1fr;
}
}
@media (max-width: 860px) {
.recordings-page {
overflow: auto;
}
.recordings-shell {
padding: 14px;
overflow: visible;
}
.recordings-toolbar {
grid-template-columns: 1fr 1fr;
}
.recordings-search,
.recordings-format-filter {
grid-column: 1 / -1;
}
.recordings-content,
.recordings-preview {
display: flex;
flex-direction: column;
overflow: visible;
}
.recordings-list,
.recordings-upload,
.recordings-preview {
flex: none;
}
.recordings-table {
min-width: 920px;
}
}

View File

@@ -1,4 +1,4 @@
import * as Logger from "./logger.js";
import * as Logger from "../utils/logger.js";
export default class Peer extends EventTarget {
constructor(connectionId, polite, config, resendIntervalMsec = 5000) {

View File

@@ -1,5 +1,5 @@
import Peer from "./peer.js";
import * as Logger from "./logger.js";
import * as Logger from "../utils/logger.js";
function uuid4() {
var temp_url = URL.createObjectURL(new Blob());
@@ -314,4 +314,4 @@ export class RenderStreaming {
this._signaling = null;
}
}
}
}

View File

@@ -5,11 +5,11 @@ import {
Touchscreen,
StateEvent,
TextEvent
} from "./inputdevice.js";
} from "../input/inputdevice.js";
import { LocalInputManager } from "./inputremoting.js";
import { GamepadHandler } from "./gamepadhandler.js";
import { PointerCorrector } from "./pointercorrect.js";
import { LocalInputManager } from "../input/inputremoting.js";
import { GamepadHandler } from "../input/gamepadhandler.js";
import { PointerCorrector } from "../input/pointercorrect.js";
export class Sender extends LocalInputManager {
constructor(elem) {

View File

@@ -1,11 +1,48 @@
import * as Logger from "./logger.js";
import * as Logger from "../utils/logger.js";
const RECORDING_SIGNAL_EVENTS = [
'recording-started',
'recording-peer-request',
'recording-stopped',
'recording-status',
'recording-answer',
'recording-candidate'
];
function parseOnMessageData(data) {
if (typeof data !== 'string') {
return data;
}
try {
return JSON.parse(data);
} catch(e) {
Logger.error(`Signaling: on-message, error: ${e}`);
return data;
}
}
function dispatchOnMessageEvent(target, data, participantId) {
const parsed = parseOnMessageData(data);
if (participantId && parsed && typeof parsed === 'object') {
parsed.participantId = participantId;
}
target.dispatchEvent(new CustomEvent('on-message', { detail: parsed }));
if (parsed && typeof parsed.type === 'string' && RECORDING_SIGNAL_EVENTS.indexOf(parsed.type) !== -1) {
const detail = parsed.data && typeof parsed.data === 'object'
? { type: parsed.type, ...parsed.data }
: parsed;
target.dispatchEvent(new CustomEvent(parsed.type, { detail }));
}
}
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 +56,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)
@@ -72,15 +109,7 @@ export class Signaling extends EventTarget {
this.dispatchEvent(new CustomEvent('candidate', { detail: msg }));
break;
case "on-message":
{
let parsed = msg.data;
if (typeof msg.data === 'string') {
try { parsed = JSON.parse(msg.data); } catch(e) {
Logger.error(`Signaling: on-message, error: ${e}`);
}
}
this.dispatchEvent(new CustomEvent('on-message', { detail: parsed }));
}
dispatchOnMessageEvent(this, msg.data, msg.participantId);
break;
default:
break;
@@ -151,16 +180,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);
@@ -199,18 +229,7 @@ export class WebSocketSignaling extends EventTarget {
this.dispatchEvent(new CustomEvent('candidate', { detail: { connectionId: msg.from, candidate: msg.data.candidate, sdpMLineIndex: msg.data.sdpMLineIndex, sdpMid: msg.data.sdpMid, participantId: msg.participantId } }));
break;
case "on-message":
{
let parsed = msg.data;
if (typeof msg.data === 'string') {
try { parsed = JSON.parse(msg.data); } catch(e) {
Logger.error(`Signaling: on-message, error: ${e}`);
}
}
if (msg.participantId) {
parsed.participantId = msg.participantId;
}
this.dispatchEvent(new CustomEvent('on-message', { detail: parsed }));
}
dispatchOnMessageEvent(this, msg.data, msg.participantId);
break;
case "participant-left":
this.dispatchEvent(new CustomEvent('participant-left', { detail: msg }));
@@ -233,6 +252,24 @@ export class WebSocketSignaling extends EventTarget {
case "invite-failed":
this.dispatchEvent(new CustomEvent('invite-failed', { detail: msg.data }));
break;
case "recording-started":
this.dispatchEvent(new CustomEvent('recording-started', { detail: msg }));
break;
case "recording-peer-request":
this.dispatchEvent(new CustomEvent('recording-peer-request', { detail: msg }));
break;
case "recording-stopped":
this.dispatchEvent(new CustomEvent('recording-stopped', { detail: msg }));
break;
case "recording-status":
this.dispatchEvent(new CustomEvent('recording-status', { detail: msg }));
break;
case "recording-answer":
this.dispatchEvent(new CustomEvent('recording-answer', { detail: msg }));
break;
case "recording-candidate":
this.dispatchEvent(new CustomEvent('recording-candidate', { detail: msg }));
break;
default:
break;
}
@@ -301,6 +338,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);
@@ -318,4 +361,25 @@ export class WebSocketSignaling extends EventTarget {
Logger.log(sendJson);
this.websocket.send(sendJson);
}
sendRecordingOffer(payload) {
this.sendMessage(payload.connectionId || '', {
type: 'recording-offer',
data: payload
});
}
sendRecordingCandidate(payload) {
this.sendMessage(payload.connectionId || '', {
type: 'recording-candidate',
data: payload
});
}
sendRecordingStatus(payload) {
this.sendMessage(payload.connectionId || '', {
type: 'recording-status',
data: payload
});
}
}

View File

@@ -1,8 +1,8 @@
import {
MemoryHelper,
} from "./memoryhelper.js";
} from "../utils/memoryhelper.js";
import { CharNumber } from "./charnumber.js";
import { CharNumber } from "../utils/charnumber.js";
import { Keymap } from "./keymap.js";
import { MouseButton } from "./mousebutton.js";
import { GamepadButton } from "./gamepadbutton.js";

View File

@@ -4,7 +4,7 @@ import {
import {
MemoryHelper
} from "./memoryhelper.js";
} from "../utils/memoryhelper.js";
export class LocalInputManager {
constructor() {

View File

@@ -1,4 +1,4 @@
import { sleep } from "./testutils";
import { sleep } from "../helpers/testutils.js";
/** @type {MockPrivateSignalingManager | MockPublicSignalingManager} */
let manager;

View File

@@ -1,4 +1,4 @@
import { sleep, getUniqueId } from './testutils';
import { sleep, getUniqueId } from '../helpers/testutils.js';
export class PeerConnectionMock extends EventTarget {
constructor(config) {
@@ -313,4 +313,4 @@ export class IceCandidateMock {
candidate;
sdpMLineIndex;
sdpMid;
}
}

View File

@@ -11,7 +11,7 @@ import {
StateEvent,
InputEvent,
TextEvent
} from "../src/inputdevice.js";
} from "../../src/input/inputdevice.js";
describe(`FourCC`, () => {
test('toInt32', () => {

Some files were not shown because too many files have changed in this diff Show More