Compare commits
11 Commits
bbe7e71274
...
serverReco
| Author | SHA1 | Date | |
|---|---|---|---|
| 37f195b48c | |||
| 600f64dc6d | |||
| f742499b33 | |||
| 3e161ff995 | |||
| 206a3ac91d | |||
| 59fc4be5cc | |||
| 66d6f92d1e | |||
| d74a0c8121 | |||
| e6dfb28ef2 | |||
| ad93ef342b | |||
| 40fd7f7e08 |
12
.vscode/tasks.json
vendored
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "dev_secure",
|
||||
"problemMatcher": [],
|
||||
"label": "npm: dev_secure",
|
||||
"detail": "ts-node ./src/index.ts -p 8080 -m private -s -k ./server.key -c ./server.crt"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 472 KiB After Width: | Height: | Size: 472 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
@@ -1,13 +1,13 @@
|
||||
import { createLogger } from './logger.js';
|
||||
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
|
||||
|
||||
77
client/public/call/chat/renderer-chat.js
Normal 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;
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
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';
|
||||
import { createLogger } from './logger.js';
|
||||
} from './signaling/connect-directory.js';
|
||||
import { createProfileSettingsController } from './controllers/profile-settings.js';
|
||||
import { createLogger } from '../shared/logger.js';
|
||||
|
||||
const logger = createLogger('connectview');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createLogger } from './logger.js';
|
||||
import { createLogger } from '../../shared/logger.js';
|
||||
|
||||
const logger = createLogger('invite');
|
||||
const DEFAULT_CALLER_NAME = '\u9080\u8bf7\u65b9';
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createLogger } from './logger.js';
|
||||
import { createLogger } from '../../shared/logger.js';
|
||||
|
||||
const logger = createLogger('profile');
|
||||
const DEFAULT_AVATAR = '/images/p1.png';
|
||||
@@ -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>
|
||||
@@ -1,15 +1,15 @@
|
||||
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 { createLogger } from './logger.js';
|
||||
import { createInviteController } from './controllers/invite-controller.js';
|
||||
import { createLogger } from '../shared/logger.js';
|
||||
|
||||
const logger = createLogger('main');
|
||||
|
||||
@@ -124,6 +124,11 @@ export class MeetingRecorder {
|
||||
this.audioSources = [];
|
||||
this.recordingStream = null;
|
||||
this.connectionId = '';
|
||||
this.layout = 'grid';
|
||||
this.onChunk = null;
|
||||
this.storeChunks = true;
|
||||
this.mixedAudioDestination = null;
|
||||
this.mixedAudioTrackIds = new Set();
|
||||
}
|
||||
|
||||
isSupported() {
|
||||
@@ -137,7 +142,7 @@ export class MeetingRecorder {
|
||||
return Boolean(this.mediaRecorder && this.mediaRecorder.state !== 'inactive');
|
||||
}
|
||||
|
||||
async start({ localStream, remoteStream, remoteStreams, connectionId } = {}) {
|
||||
async start({ localStream, remoteStream, remoteStreams, connectionId, layout, onChunk, storeChunks } = {}) {
|
||||
if (this.isRecording()) {
|
||||
throw new Error('会议正在录制中');
|
||||
}
|
||||
@@ -156,7 +161,11 @@ export class MeetingRecorder {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -179,6 +188,16 @@ export class MeetingRecorder {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -203,16 +222,26 @@ export class MeetingRecorder {
|
||||
}
|
||||
|
||||
this.audioContext = new AudioContextCtor();
|
||||
const destination = this.audioContext.createMediaStreamDestination();
|
||||
this.mixedAudioDestination = this.audioContext.createMediaStreamDestination();
|
||||
audioTracks.forEach(track => this._connectAudioTrack(track));
|
||||
|
||||
audioTracks.forEach(track => {
|
||||
const sourceStream = new this.window.MediaStream([track]);
|
||||
const source = this.audioContext.createMediaStreamSource(sourceStream);
|
||||
source.connect(destination);
|
||||
this.audioSources.push(source);
|
||||
});
|
||||
return this.mixedAudioDestination.stream.getAudioTracks()[0] || null;
|
||||
}
|
||||
|
||||
return destination.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) {
|
||||
@@ -224,7 +253,17 @@ export class MeetingRecorder {
|
||||
this.mediaRecorder = new MediaRecorderCtor(stream, options);
|
||||
this.mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data && event.data.size > 0) {
|
||||
this.chunks.push(event.data);
|
||||
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) => {
|
||||
@@ -235,9 +274,9 @@ export class MeetingRecorder {
|
||||
this.cleanup();
|
||||
};
|
||||
this.mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(this.chunks, { type: this.mediaRecorder.mimeType || 'video/webm' });
|
||||
const filename = this.buildFilename();
|
||||
const mimeType = blob.type || this.mediaRecorder.mimeType || 'video/webm';
|
||||
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 });
|
||||
@@ -267,6 +306,15 @@ export class MeetingRecorder {
|
||||
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) {
|
||||
@@ -328,9 +376,13 @@ export class MeetingRecorder {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createParticipantTile, getParticipantTile } from './renderer-participant-grid.js';
|
||||
import { createLogger } from './logger.js';
|
||||
import { createParticipantTile, getParticipantTile } from '../participants/renderer-participant-grid.js';
|
||||
import { createLogger } from '../../shared/logger.js';
|
||||
|
||||
const logger = createLogger('renderer-media');
|
||||
|
||||
175
client/public/call/media/server-recording-peer.js
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,8 +22,8 @@ import {
|
||||
removeParticipantTile,
|
||||
renderParticipantStreamMedia,
|
||||
renderSingleRemoteStreamMedia
|
||||
} from './renderer-media.js';
|
||||
import { createLogger } from './logger.js';
|
||||
} from '../media/renderer-media.js';
|
||||
import { createLogger } from '../../shared/logger.js';
|
||||
|
||||
const logger = createLogger('renderer');
|
||||
|
||||
@@ -513,7 +513,7 @@ class UIRenderer {
|
||||
renderCallEnded() {
|
||||
logger.debug('Call ended');
|
||||
clearParticipantGrid(this.elements.participantGrid);
|
||||
window.location.href = './endcall/endcall.html';
|
||||
window.location.href = '/endcall/';
|
||||
}
|
||||
|
||||
renderParticipantLeft(connectionId) {
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Signaling, WebSocketSignaling } from "../../module/signaling.js";
|
||||
import { createLogger } from './logger.js';
|
||||
import { Signaling, WebSocketSignaling } from '/module/core/signaling.js';
|
||||
import { createLogger } from '../../shared/logger.js';
|
||||
|
||||
const logger = createLogger('signaling');
|
||||
|
||||
@@ -91,7 +91,16 @@ export function buildSocketUserInfoPayload(userInfo, localUser) {
|
||||
}
|
||||
|
||||
export function sendSocketUserInfo(signaling, payload) {
|
||||
if (!signaling || typeof signaling.sendMessage !== 'function') {
|
||||
if (!signaling) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof signaling.sendUserInfo === 'function') {
|
||||
signaling.sendUserInfo(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof signaling.sendMessage !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,17 +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 { createLogger } from './logger.js';
|
||||
import { MeetingRecorder } from './meeting-recorder.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 = {
|
||||
@@ -28,6 +42,9 @@ class CallStateManager {
|
||||
this.listeners = [];
|
||||
this.socketEventHandlers = {};
|
||||
this._inviteEventSignaling = null;
|
||||
this._recordingEventSignaling = null;
|
||||
this.serverRecordingSession = null;
|
||||
this.serverRecordingPeer = null;
|
||||
this.meetingRecorder = new MeetingRecorder();
|
||||
}
|
||||
subscribe(callback) {
|
||||
@@ -112,12 +129,73 @@ class CallStateManager {
|
||||
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('会议连接成功后才能开始录制');
|
||||
@@ -164,9 +242,14 @@ class CallStateManager {
|
||||
}
|
||||
async uploadRecording({ blob, filename }) {
|
||||
const formData = new FormData();
|
||||
const people = this.buildRecordingPeopleMetadata();
|
||||
formData.append('meetingId', this.connectionId || this.state.session.id || 'unknown');
|
||||
formData.append('userId', this.state.session.localUser.id || '');
|
||||
formData.append('filename', filename);
|
||||
if (people.host) {
|
||||
formData.append('host', JSON.stringify(people.host));
|
||||
}
|
||||
formData.append('participants', JSON.stringify(people.participants));
|
||||
formData.append('recording', blob, filename);
|
||||
|
||||
const response = await fetch('/api/recordings', {
|
||||
@@ -181,9 +264,50 @@ class CallStateManager {
|
||||
|
||||
return responseBody;
|
||||
}
|
||||
buildRecordingPeopleMetadata() {
|
||||
const localUser = this.state.session.localUser || {};
|
||||
const remoteUser = this.state.session.remoteUser || {};
|
||||
const members = Object.entries(this.state.participants || {}).map(([participantId, participant]) => (
|
||||
this._buildRecordingPerson(participant, participant.role || 'participant', participantId)
|
||||
));
|
||||
const remoteHost = members.find(member => member.role === 'host');
|
||||
const localPerson = this._buildRecordingPerson(
|
||||
localUser,
|
||||
this.role === 'host' || localUser.isHost ? 'host' : 'participant',
|
||||
this.selfParticipantId || (this.role === 'host' ? 'host' : 'local')
|
||||
);
|
||||
|
||||
if (localPerson.role === 'host') {
|
||||
return {
|
||||
host: localPerson,
|
||||
participants: members.filter(member => member.role !== 'host')
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
host: remoteHost || this._buildRecordingPerson(remoteUser, 'host', 'host'),
|
||||
participants: [
|
||||
localPerson,
|
||||
...members.filter(member => member.role !== 'host' && member.participantId !== localPerson.participantId)
|
||||
]
|
||||
};
|
||||
}
|
||||
_buildRecordingPerson(user = {}, role = 'participant', participantId = '') {
|
||||
return {
|
||||
participantId,
|
||||
userId: user.id || user.userId || '',
|
||||
id: user.id || user.userId || '',
|
||||
name: user.name || '',
|
||||
avatar: user.avatar || '',
|
||||
role,
|
||||
status: user.status || '',
|
||||
mediaState: user.mediaState ? { ...user.mediaState } : undefined
|
||||
};
|
||||
}
|
||||
async _updateLocalMediaRefactored(mediaType, value) {
|
||||
if (mediaType === 'video' && value) {
|
||||
await this._enableLocalVideo();
|
||||
this._refreshServerRecordingPeer();
|
||||
this._notifyUserListUpdate();
|
||||
return;
|
||||
}
|
||||
@@ -196,6 +320,7 @@ class CallStateManager {
|
||||
if (mediaType === 'audio') {
|
||||
this._setLocalAudioTrackEnabled(value);
|
||||
}
|
||||
this._refreshServerRecordingPeer();
|
||||
this._notifyUserListUpdate();
|
||||
}
|
||||
async _enableLocalVideo() {
|
||||
@@ -396,6 +521,8 @@ class CallStateManager {
|
||||
await this._startConnection(connectionId);
|
||||
}
|
||||
_registerCallbacks() {
|
||||
this._ensureServerRecordingPeer();
|
||||
this._bindRecordingSignalHandlers();
|
||||
this.renderstreaming.onNewPeer = (participantId) => {
|
||||
logger.debug(`New peer created for ${participantId}, adding local tracks`);
|
||||
if (this.state.localStream) {
|
||||
@@ -489,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);
|
||||
@@ -507,6 +674,9 @@ class CallStateManager {
|
||||
}
|
||||
this.clearStatsMessage();
|
||||
this.stopNetworkQualityDetection();
|
||||
if (this.serverRecordingPeer) {
|
||||
this.serverRecordingPeer.stop();
|
||||
}
|
||||
if (this.durationInterval) {
|
||||
clearInterval(this.durationInterval);
|
||||
this.durationInterval = null;
|
||||
@@ -629,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) {
|
||||
@@ -665,29 +945,34 @@ class CallStateManager {
|
||||
_handleMediaStateChangedMessage(data) {
|
||||
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;
|
||||
}
|
||||
logger.debug('Received media-state-changed from Host, updating remoteUser:', data.data);
|
||||
this.updateRemoteMedia(data.data, data.participantId);
|
||||
this._notifyParticipantsUpdate();
|
||||
if (this._updateRemoteMediaIfChanged(data.data, data.participantId)) {
|
||||
this._notifyParticipantsUpdate();
|
||||
} else {
|
||||
logger.debug('媒体状态未变化,跳过远端用户更新:', data.participantId);
|
||||
}
|
||||
}
|
||||
_handleUserInfoMessage(data) {
|
||||
logger.debug('收到用户信息:', data.data, 'from participant:', data.participantId);
|
||||
@@ -779,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);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createLogger } from '../logger.js';
|
||||
import { createLogger } from '../shared/logger.js';
|
||||
|
||||
const logger = createLogger('legacy-connect');
|
||||
/**
|
||||
@@ -6,7 +6,7 @@ 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
|
||||
|
||||
@@ -20,7 +20,7 @@ function joinCall() {
|
||||
localStorage.setItem('connectionId', connectionId);
|
||||
|
||||
// 跳转到通话界面
|
||||
window.location.href = '../index.html';
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
showNotification('请输入连接ID', 'error');
|
||||
}
|
||||
@@ -39,7 +39,7 @@ function createCall() {
|
||||
localStorage.setItem('connectionId', connectionId);
|
||||
|
||||
// 跳转到通话界面
|
||||
window.location.href = '../index.html';
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>VideoCall - 重定向</title>
|
||||
<script>
|
||||
// 重定向到SPA入口页面(index.html)
|
||||
window.location.href = '../index.html';
|
||||
window.location.href = '/';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -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/';
|
||||
}
|
||||
|
||||
// 绑定事件监听器
|
||||
|
||||
@@ -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">
|
||||
<!--
|
||||
@@ -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="min-h-screen w-screen text-white bg-grid recordings-page">
|
||||
@@ -56,11 +56,31 @@
|
||||
<input id="searchInput" type="search" placeholder="搜索会议、文件或用户" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<select id="typeFilter" class="recordings-select">
|
||||
<option value="all">全部格式</option>
|
||||
<option value="mp4">MP4</option>
|
||||
<option value="webm">WebM</option>
|
||||
</select>
|
||||
<div class="recordings-format-filter" id="typeFilterControl">
|
||||
<select id="typeFilter" class="recordings-select-native" aria-hidden="true" tabindex="-1">
|
||||
<option value="all">全部格式</option>
|
||||
<option value="mp4">MP4</option>
|
||||
<option value="webm">WebM</option>
|
||||
</select>
|
||||
<button id="typeFilterButton" class="recordings-filter-trigger" type="button" aria-haspopup="listbox" aria-expanded="false">
|
||||
<span id="typeFilterText">全部格式</span>
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
<div id="typeFilterMenu" class="recordings-filter-menu hidden" role="listbox" aria-label="录制格式筛选">
|
||||
<button class="recordings-filter-option is-active" type="button" role="option" aria-selected="true" data-type-value="all">
|
||||
<span>全部格式</span>
|
||||
<small>所有录制</small>
|
||||
</button>
|
||||
<button class="recordings-filter-option" type="button" role="option" aria-selected="false" data-type-value="mp4">
|
||||
<span>MP4</span>
|
||||
<small>标准视频</small>
|
||||
</button>
|
||||
<button class="recordings-filter-option" type="button" role="option" aria-selected="false" data-type-value="webm">
|
||||
<span>WebM</span>
|
||||
<small>网页录制</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="recordings-content">
|
||||
@@ -127,6 +147,8 @@
|
||||
<tr>
|
||||
<th>文件</th>
|
||||
<th>会议</th>
|
||||
<th>房主</th>
|
||||
<th>参与者</th>
|
||||
<th>大小</th>
|
||||
<th>上传时间</th>
|
||||
<th>操作</th>
|
||||
@@ -179,7 +201,7 @@
|
||||
<input id="editOriginalFilename" class="recordings-input" type="text" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="recordings-label" for="editUserId">用户 ID</label>
|
||||
<label class="recordings-label" for="editUserId">房主用户 ID</label>
|
||||
<input id="editUserId" class="recordings-input" type="text">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,10 @@ const elements = {
|
||||
refreshBtn: document.getElementById('refreshBtn'),
|
||||
searchInput: document.getElementById('searchInput'),
|
||||
typeFilter: document.getElementById('typeFilter'),
|
||||
typeFilterControl: document.getElementById('typeFilterControl'),
|
||||
typeFilterButton: document.getElementById('typeFilterButton'),
|
||||
typeFilterText: document.getElementById('typeFilterText'),
|
||||
typeFilterMenu: document.getElementById('typeFilterMenu'),
|
||||
clearSearchBtn: document.getElementById('clearSearchBtn'),
|
||||
uploadForm: document.getElementById('uploadForm'),
|
||||
uploadBtn: document.getElementById('uploadBtn'),
|
||||
@@ -41,6 +45,12 @@ const elements = {
|
||||
notificationText: document.getElementById('notificationText')
|
||||
};
|
||||
|
||||
const typeFilterLabels = {
|
||||
all: '全部格式',
|
||||
mp4: 'MP4',
|
||||
webm: 'WebM'
|
||||
};
|
||||
|
||||
function recordingKey(recording) {
|
||||
return `${recording.meetingId}/${recording.filename}`;
|
||||
}
|
||||
@@ -89,6 +99,87 @@ function formatDate(value) {
|
||||
});
|
||||
}
|
||||
|
||||
function getPersonId(person) {
|
||||
return person?.userId || person?.id || 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);
|
||||
@@ -143,7 +234,8 @@ function applyFilters() {
|
||||
recording.meetingId,
|
||||
recording.filename,
|
||||
recording.originalFilename,
|
||||
recording.userId
|
||||
recording.userId,
|
||||
getPeopleSearchText(recording)
|
||||
].join(' ').toLowerCase();
|
||||
return (type === 'all' || extension === type) && (!query || haystack.includes(query));
|
||||
});
|
||||
@@ -169,6 +261,8 @@ function renderTable() {
|
||||
const key = recordingKey(recording);
|
||||
const active = key === state.selectedKey ? 'recordings-row-active' : '';
|
||||
const ext = escapeHtml((recording.filename || '').split('.').pop().toUpperCase());
|
||||
const host = getRecordingHost(recording);
|
||||
const participants = getRecordingParticipants(recording);
|
||||
|
||||
return `
|
||||
<tr class="${active}" data-key="${escapeHtml(key)}">
|
||||
@@ -182,6 +276,8 @@ function renderTable() {
|
||||
</button>
|
||||
</td>
|
||||
<td>${escapeHtml(recording.meetingId)}</td>
|
||||
<td>${escapeHtml(renderPersonSummary(host))}</td>
|
||||
<td>${participants.length}</td>
|
||||
<td>${formatBytes(recording.size)}</td>
|
||||
<td>${formatDate(recording.uploadedAt)}</td>
|
||||
<td>
|
||||
@@ -222,6 +318,8 @@ function selectRecording(recording) {
|
||||
}
|
||||
|
||||
state.selectedKey = recordingKey(recording);
|
||||
const host = getRecordingHost(recording);
|
||||
const participants = getRecordingParticipants(recording);
|
||||
elements.previewVideo.src = recording.streamUrl;
|
||||
elements.previewPlaceholder.classList.add('hidden');
|
||||
elements.previewTitle.textContent = recording.originalFilename || recording.filename;
|
||||
@@ -232,6 +330,14 @@ function selectRecording(recording) {
|
||||
<div class="recordings-detail-row"><span>大小</span><strong>${formatBytes(recording.size)}</strong></div>
|
||||
<div class="recordings-detail-row"><span>用户 ID</span><strong>${escapeHtml(recording.userId || '-')}</strong></div>
|
||||
<div class="recordings-detail-row"><span>上传时间</span><strong>${formatDate(recording.uploadedAt)}</strong></div>
|
||||
<div class="recordings-people-section">
|
||||
<div class="recordings-people-title">房主</div>
|
||||
${renderPeopleList(host ? [host] : [])}
|
||||
</div>
|
||||
<div class="recordings-people-section">
|
||||
<div class="recordings-people-title">参与者 (${participants.length})</div>
|
||||
${renderPeopleList(participants)}
|
||||
</div>
|
||||
<div class="recordings-preview-actions">
|
||||
<a class="recordings-primary-btn" href="${escapeHtml(recording.downloadUrl)}">
|
||||
<i class="fas fa-download"></i>
|
||||
@@ -349,9 +455,32 @@ function bindEvents() {
|
||||
elements.refreshBtn.addEventListener('click', loadRecordings);
|
||||
elements.searchInput.addEventListener('input', applyFilters);
|
||||
elements.typeFilter.addEventListener('change', applyFilters);
|
||||
elements.typeFilterButton.addEventListener('click', () => {
|
||||
setTypeFilterMenuOpen(!elements.typeFilterControl.classList.contains('is-open'));
|
||||
});
|
||||
elements.typeFilterMenu.addEventListener('click', (event) => {
|
||||
const option = event.target.closest('[data-type-value]');
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTypeFilter(option.dataset.typeValue);
|
||||
setTypeFilterMenuOpen(false);
|
||||
applyFilters();
|
||||
});
|
||||
document.addEventListener('click', (event) => {
|
||||
if (!elements.typeFilterControl.contains(event.target)) {
|
||||
setTypeFilterMenuOpen(false);
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setTypeFilterMenuOpen(false);
|
||||
}
|
||||
});
|
||||
elements.clearSearchBtn.addEventListener('click', () => {
|
||||
elements.searchInput.value = '';
|
||||
elements.typeFilter.value = 'all';
|
||||
setTypeFilter('all');
|
||||
applyFilters();
|
||||
});
|
||||
elements.recordingFile.addEventListener('change', () => {
|
||||
@@ -397,4 +526,5 @@ function bindEvents() {
|
||||
}
|
||||
|
||||
bindEvents();
|
||||
setTypeFilter(elements.typeFilter.value);
|
||||
loadRecordings();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
18
client/public/shared/dom.js
Normal 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;
|
||||
}
|
||||
@@ -266,11 +266,13 @@ body {
|
||||
}
|
||||
|
||||
.recordings-toolbar {
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
min-height: 76px;
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(112px, max-content)) minmax(220px, 1fr) 140px;
|
||||
grid-template-columns: repeat(3, minmax(112px, max-content)) minmax(220px, 1fr) 152px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -296,7 +298,6 @@ body {
|
||||
}
|
||||
|
||||
.recordings-search,
|
||||
.recordings-select,
|
||||
.recordings-input {
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
@@ -327,13 +328,118 @@ body {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.recordings-select {
|
||||
padding: 0 12px;
|
||||
outline: 0;
|
||||
.recordings-format-filter {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
min-width: 152px;
|
||||
}
|
||||
|
||||
.recordings-select option {
|
||||
color: #0f172a;
|
||||
.recordings-select-native {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.recordings-filter-trigger {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
padding: 0 12px 0 14px;
|
||||
border: 1px solid rgba(129, 140, 248, 0.45);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, rgba(30, 41, 59, 0.96), rgba(15, 23, 42, 0.94));
|
||||
color: #e5e7eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 10px 24px rgba(2, 6, 23, 0.24);
|
||||
}
|
||||
|
||||
.recordings-filter-trigger i {
|
||||
color: #c7d2fe;
|
||||
font-size: 12px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.recordings-filter-trigger:hover,
|
||||
.recordings-format-filter.is-open .recordings-filter-trigger {
|
||||
border-color: rgba(165, 180, 252, 0.9);
|
||||
background: linear-gradient(180deg, rgba(49, 46, 129, 0.72), rgba(30, 41, 59, 0.96));
|
||||
}
|
||||
|
||||
.recordings-filter-trigger:focus-visible {
|
||||
border-color: rgba(129, 140, 248, 0.95);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.recordings-format-filter.is-open .recordings-filter-trigger i {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.recordings-filter-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 40;
|
||||
padding: 6px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 14px;
|
||||
background: rgba(15, 23, 42, 0.98);
|
||||
box-shadow: 0 18px 40px rgba(2, 6, 23, 0.42), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.recordings-filter-option {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 50px;
|
||||
padding: 8px 34px 8px 10px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: #e2e8f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s, color 0.18s;
|
||||
}
|
||||
|
||||
.recordings-filter-option span {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.recordings-filter-option small {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.recordings-filter-option:hover,
|
||||
.recordings-filter-option.is-active {
|
||||
background: rgba(79, 70, 229, 0.22);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.recordings-filter-option.is-active::after {
|
||||
content: "\f00c";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 12px;
|
||||
transform: translateY(-50%);
|
||||
color: #a5b4fc;
|
||||
font-family: "Font Awesome 6 Free";
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.recordings-content {
|
||||
@@ -342,6 +448,7 @@ body {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(420px, 1fr) 360px;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.recordings-upload,
|
||||
@@ -415,7 +522,6 @@ body {
|
||||
}
|
||||
|
||||
.recordings-input:focus,
|
||||
.recordings-select:focus,
|
||||
.recordings-search:focus-within {
|
||||
border-color: rgba(129, 140, 248, 0.9);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.22);
|
||||
@@ -507,7 +613,33 @@ body {
|
||||
.recordings-table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
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 {
|
||||
@@ -534,11 +666,13 @@ body {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.recordings-table th:nth-child(1) { width: 38%; }
|
||||
.recordings-table th:nth-child(2) { width: 18%; }
|
||||
.recordings-table th:nth-child(3) { width: 12%; }
|
||||
.recordings-table th:nth-child(4) { width: 18%; }
|
||||
.recordings-table th:nth-child(5) { width: 14%; }
|
||||
.recordings-table th:nth-child(1) { width: 28%; }
|
||||
.recordings-table th:nth-child(2) { width: 12%; }
|
||||
.recordings-table th:nth-child(3) { width: 15%; }
|
||||
.recordings-table th:nth-child(4) { width: 8%; }
|
||||
.recordings-table th:nth-child(5) { width: 9%; }
|
||||
.recordings-table th:nth-child(6) { width: 14%; }
|
||||
.recordings-table th:nth-child(7) { width: 14%; }
|
||||
|
||||
.recordings-table tbody tr {
|
||||
transition: background 0.2s;
|
||||
@@ -670,6 +804,53 @@ body {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.recordings-people-section {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.recordings-people-title {
|
||||
margin-bottom: 10px;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.recordings-person {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.recordings-person img {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
object-fit: cover;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.recordings-person strong,
|
||||
.recordings-person span {
|
||||
display: block;
|
||||
max-width: 230px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recordings-person strong {
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.recordings-person span,
|
||||
.recordings-person-empty {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.recordings-preview-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -738,7 +919,7 @@ body {
|
||||
}
|
||||
|
||||
.recordings-search,
|
||||
.recordings-select {
|
||||
.recordings-format-filter {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@@ -746,6 +927,7 @@ body {
|
||||
.recordings-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.recordings-list,
|
||||
@@ -755,6 +937,6 @@ body {
|
||||
}
|
||||
|
||||
.recordings-table {
|
||||
min-width: 760px;
|
||||
min-width: 920px;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
|
||||
import {
|
||||
MemoryHelper
|
||||
} from "./memoryhelper.js";
|
||||
} from "../utils/memoryhelper.js";
|
||||
|
||||
export class LocalInputManager {
|
||||
constructor() {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sleep } from "./testutils";
|
||||
import { sleep } from "../helpers/testutils.js";
|
||||
|
||||
/** @type {MockPrivateSignalingManager | MockPublicSignalingManager} */
|
||||
let manager;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
StateEvent,
|
||||
InputEvent,
|
||||
TextEvent
|
||||
} from "../src/inputdevice.js";
|
||||
} from "../../src/input/inputdevice.js";
|
||||
|
||||
describe(`FourCC`, () => {
|
||||
test('toInt32', () => {
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
KeyboardState,
|
||||
TouchscreenState,
|
||||
GamepadState
|
||||
} from "../src/inputdevice.js";
|
||||
} from "../../src/input/inputdevice.js";
|
||||
|
||||
import {
|
||||
MessageType,
|
||||
@@ -12,14 +12,14 @@ import {
|
||||
NewEventsMsg,
|
||||
RemoveDeviceMsg,
|
||||
InputRemoting,
|
||||
} from "../src/inputremoting.js";
|
||||
} from "../../src/input/inputremoting.js";
|
||||
|
||||
import {
|
||||
Sender,
|
||||
Observer
|
||||
} from "../src/sender.js";
|
||||
} from "../../src/core/sender.js";
|
||||
|
||||
import {DOMRect} from "./domrect.js";
|
||||
import {DOMRect} from "../helpers/domrect.js";
|
||||
|
||||
describe(`InputRemoting`, () => {
|
||||
let sender = null;
|
||||
@@ -129,4 +129,4 @@ test('create RemoveDeviceMsg', () => {
|
||||
expect(msg.data).toBeInstanceOf(ArrayBuffer);
|
||||
expect(msg.data.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MeetingRecorder } from '../public/meeting-recorder.js';
|
||||
import { jest } from '@jest/globals';
|
||||
import { MeetingRecorder } from '../../public/call/media/meeting-recorder.js';
|
||||
|
||||
class MediaStreamMock {
|
||||
constructor(tracks = []) {
|
||||
@@ -107,7 +108,7 @@ describe('MeetingRecorder', () => {
|
||||
const result = await recorder.stop();
|
||||
|
||||
expect(result.filename).toContain('meeting-recording-123-456-789');
|
||||
expect(result.mimeType).toBe('video/mp4;codecs=avc1.42E01E,mp4a.40.2');
|
||||
expect(result.mimeType.toLowerCase()).toBe('video/mp4;codecs=avc1.42e01e,mp4a.40.2');
|
||||
expect(result.filename).toMatch(/\.mp4$/);
|
||||
expect(windowRef.URL.createObjectURL).not.toHaveBeenCalled();
|
||||
expect(recorder.isRecording()).toBe(false);
|
||||