优化目录结构
This commit is contained in:
80
client/public/call/media/media-config.js
Normal file
80
client/public/call/media/media-config.js
Normal file
@@ -0,0 +1,80 @@
|
||||
export const AUDIO_CONFIG = {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true
|
||||
};
|
||||
|
||||
export const VAD_CONFIG = {
|
||||
threshold: 15,
|
||||
debounceTime: 500,
|
||||
fftSize: 256
|
||||
};
|
||||
|
||||
export const MEDIA_CONSTRAINTS = {
|
||||
video: {
|
||||
width: { ideal: 1920, max: 1920 },
|
||||
height: { ideal: 1080, max: 1080 },
|
||||
frameRate: { ideal: 30, max: 30 }
|
||||
},
|
||||
audio: AUDIO_CONFIG
|
||||
};
|
||||
|
||||
export const VIDEO_ONLY_CONSTRAINT = {
|
||||
video: {
|
||||
width: { ideal: 1920, max: 1920 },
|
||||
height: { ideal: 1080, max: 1080 },
|
||||
frameRate: { ideal: 30, max: 30 }
|
||||
},
|
||||
audio: false
|
||||
};
|
||||
|
||||
const TARGET_RESOLUTION_BITRATE_MAP = {
|
||||
270: 1000000,
|
||||
480: 1500000,
|
||||
720: 2500000,
|
||||
1080: 4000000,
|
||||
1440: 6000000
|
||||
};
|
||||
|
||||
export function buildVideoConstraints(savedResolution) {
|
||||
if (!savedResolution) {
|
||||
return MEDIA_CONSTRAINTS.video;
|
||||
}
|
||||
|
||||
return {
|
||||
width: { ideal: savedResolution.width, max: savedResolution.width },
|
||||
height: { ideal: savedResolution.height, max: savedResolution.height },
|
||||
frameRate: { ideal: 30, max: 30 }
|
||||
};
|
||||
}
|
||||
|
||||
export function getResolutionLabel(height) {
|
||||
if (height >= 1440) {
|
||||
return '2K 1440p';
|
||||
}
|
||||
if (height >= 1080) {
|
||||
return '1080p 超清';
|
||||
}
|
||||
if (height >= 720) {
|
||||
return '720p 高清';
|
||||
}
|
||||
return '480p 流畅';
|
||||
}
|
||||
|
||||
export function getTargetResolutionBitrate(height) {
|
||||
return TARGET_RESOLUTION_BITRATE_MAP[height] || 2500000;
|
||||
}
|
||||
|
||||
export function getAdaptiveVideoBitrate(height) {
|
||||
const supportedHeights = Object.keys(TARGET_RESOLUTION_BITRATE_MAP).map(Number).sort((a, b) => a - b);
|
||||
let maxBitrate = TARGET_RESOLUTION_BITRATE_MAP[1080];
|
||||
|
||||
for (const supportedHeight of supportedHeights) {
|
||||
if (height <= supportedHeight) {
|
||||
return TARGET_RESOLUTION_BITRATE_MAP[supportedHeight];
|
||||
}
|
||||
maxBitrate = TARGET_RESOLUTION_BITRATE_MAP[supportedHeight];
|
||||
}
|
||||
|
||||
return maxBitrate;
|
||||
}
|
||||
62
client/public/call/media/media-monitoring.js
Normal file
62
client/public/call/media/media-monitoring.js
Normal file
@@ -0,0 +1,62 @@
|
||||
export function createAudioAnalyser(stream, fftSize) {
|
||||
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
|
||||
const audioContext = new AudioContextCtor();
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
const analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = fftSize;
|
||||
source.connect(analyser);
|
||||
|
||||
return {
|
||||
audioContext,
|
||||
analyser,
|
||||
dataArray: new Uint8Array(analyser.frequencyBinCount)
|
||||
};
|
||||
}
|
||||
|
||||
export function getAudioLevel(analyser, dataArray) {
|
||||
analyser.getByteTimeDomainData(dataArray);
|
||||
|
||||
let sum = 0;
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
const amplitude = dataArray[i] - 128;
|
||||
sum += amplitude * amplitude;
|
||||
}
|
||||
|
||||
const rms = Math.sqrt(sum / dataArray.length);
|
||||
return rms / 128;
|
||||
}
|
||||
|
||||
function formatPacketLossRate(packetsLost, packetsReceived) {
|
||||
if (packetsReceived <= 0) {
|
||||
return '0%';
|
||||
}
|
||||
|
||||
return `${((packetsLost / (packetsLost + packetsReceived)) * 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
export function buildStatsLogPayload(networkQuality, statsSummary) {
|
||||
return {
|
||||
networkQuality,
|
||||
video: {
|
||||
'Packets Lost': statsSummary.video.packetsLost,
|
||||
'Packets Received': statsSummary.video.packetsReceived,
|
||||
'Packet Loss Rate': formatPacketLossRate(
|
||||
statsSummary.video.packetsLost,
|
||||
statsSummary.video.packetsReceived
|
||||
),
|
||||
'Jitter': `${(statsSummary.video.jitter * 1000).toFixed(2)}ms`,
|
||||
'Round Trip Time': `${(statsSummary.video.roundTripTime * 1000).toFixed(2)}ms`,
|
||||
'FPS': statsSummary.video.fps.toFixed(1),
|
||||
'Bitrate': `${statsSummary.video.bitrate}kbps`
|
||||
},
|
||||
audio: {
|
||||
'Packets Lost': statsSummary.audio.packetsLost,
|
||||
'Packets Received': statsSummary.audio.packetsReceived,
|
||||
'Packet Loss Rate': formatPacketLossRate(
|
||||
statsSummary.audio.packetsLost,
|
||||
statsSummary.audio.packetsReceived
|
||||
),
|
||||
'Jitter': `${(statsSummary.audio.jitter * 1000).toFixed(2)}ms`
|
||||
}
|
||||
};
|
||||
}
|
||||
336
client/public/call/media/meeting-recorder.js
Normal file
336
client/public/call/media/meeting-recorder.js
Normal file
@@ -0,0 +1,336 @@
|
||||
const DEFAULT_WIDTH = 1280;
|
||||
const DEFAULT_HEIGHT = 720;
|
||||
const DEFAULT_FPS = 30;
|
||||
const MIME_TYPE_CANDIDATES = [
|
||||
{ mimeType: 'video/mp4;codecs=avc1.42E01E,mp4a.40.2', extension: 'mp4' },
|
||||
{ mimeType: 'video/mp4', extension: 'mp4' },
|
||||
{ mimeType: 'video/webm;codecs=vp9,opus', extension: 'webm' },
|
||||
{ mimeType: 'video/webm;codecs=vp8,opus', extension: 'webm' },
|
||||
{ mimeType: 'video/webm', extension: 'webm' }
|
||||
];
|
||||
|
||||
function getSupportedFormat(mediaRecorderCtor) {
|
||||
if (!mediaRecorderCtor || typeof mediaRecorderCtor.isTypeSupported !== 'function') {
|
||||
return {
|
||||
mimeType: '',
|
||||
extension: 'webm'
|
||||
};
|
||||
}
|
||||
|
||||
return MIME_TYPE_CANDIDATES.find(format => mediaRecorderCtor.isTypeSupported(format.mimeType)) || {
|
||||
mimeType: '',
|
||||
extension: 'webm'
|
||||
};
|
||||
}
|
||||
|
||||
function isElementVisible(element) {
|
||||
if (!element || element.classList.contains('hidden')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
|
||||
function drawVideoCover(context, video, x, y, width, height) {
|
||||
const videoWidth = video.videoWidth || width;
|
||||
const videoHeight = video.videoHeight || height;
|
||||
const sourceRatio = videoWidth / videoHeight;
|
||||
const targetRatio = width / height;
|
||||
let sourceX = 0;
|
||||
let sourceY = 0;
|
||||
let sourceWidth = videoWidth;
|
||||
let sourceHeight = videoHeight;
|
||||
|
||||
if (sourceRatio > targetRatio) {
|
||||
sourceWidth = videoHeight * targetRatio;
|
||||
sourceX = (videoWidth - sourceWidth) / 2;
|
||||
} else {
|
||||
sourceHeight = videoWidth / targetRatio;
|
||||
sourceY = (videoHeight - sourceHeight) / 2;
|
||||
}
|
||||
|
||||
context.drawImage(video, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
|
||||
}
|
||||
|
||||
function drawEmptyFrame(context, canvas) {
|
||||
context.fillStyle = '#111827';
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillStyle = '#9ca3af';
|
||||
context.font = '24px sans-serif';
|
||||
context.textAlign = 'center';
|
||||
context.textBaseline = 'middle';
|
||||
context.fillText('等待会议画面...', canvas.width / 2, canvas.height / 2);
|
||||
}
|
||||
|
||||
function drawGrid(context, videos, canvas) {
|
||||
const columns = Math.ceil(Math.sqrt(videos.length));
|
||||
const rows = Math.ceil(videos.length / columns);
|
||||
const gap = 8;
|
||||
const tileWidth = (canvas.width - gap * (columns - 1)) / columns;
|
||||
const tileHeight = (canvas.height - gap * (rows - 1)) / rows;
|
||||
|
||||
videos.forEach((video, index) => {
|
||||
const column = index % columns;
|
||||
const row = Math.floor(index / columns);
|
||||
const x = column * (tileWidth + gap);
|
||||
const y = row * (tileHeight + gap);
|
||||
drawVideoCover(context, video, x, y, tileWidth, tileHeight);
|
||||
});
|
||||
}
|
||||
|
||||
function drawLocalPreview(context, localVideo, canvas) {
|
||||
const previewWidth = Math.floor(canvas.width * 0.22);
|
||||
const previewHeight = Math.floor(previewWidth * 9 / 16);
|
||||
const margin = 24;
|
||||
const x = canvas.width - previewWidth - margin;
|
||||
const y = canvas.height - previewHeight - margin;
|
||||
|
||||
context.fillStyle = 'rgba(0, 0, 0, 0.4)';
|
||||
context.fillRect(x - 4, y - 4, previewWidth + 8, previewHeight + 8);
|
||||
drawVideoCover(context, localVideo, x, y, previewWidth, previewHeight);
|
||||
}
|
||||
|
||||
function collectStreams({ localStream, remoteStream, remoteStreams } = {}) {
|
||||
return [
|
||||
localStream,
|
||||
remoteStream,
|
||||
...Object.values(remoteStreams || {})
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function collectLiveAudioTracks(streams) {
|
||||
return streams.flatMap(stream => stream.getAudioTracks())
|
||||
.filter(track => track.readyState !== 'ended');
|
||||
}
|
||||
|
||||
export class MeetingRecorder {
|
||||
constructor({
|
||||
documentRef = document,
|
||||
windowRef = window,
|
||||
width = DEFAULT_WIDTH,
|
||||
height = DEFAULT_HEIGHT,
|
||||
fps = DEFAULT_FPS
|
||||
} = {}) {
|
||||
this.document = documentRef;
|
||||
this.window = windowRef;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.fps = fps;
|
||||
this.mediaRecorder = null;
|
||||
this.chunks = [];
|
||||
this.animationFrameId = null;
|
||||
this.audioContext = null;
|
||||
this.audioSources = [];
|
||||
this.recordingStream = null;
|
||||
this.connectionId = '';
|
||||
}
|
||||
|
||||
isSupported() {
|
||||
return Boolean(
|
||||
this.window.MediaRecorder &&
|
||||
this.document.createElement('canvas').captureStream
|
||||
);
|
||||
}
|
||||
|
||||
isRecording() {
|
||||
return Boolean(this.mediaRecorder && this.mediaRecorder.state !== 'inactive');
|
||||
}
|
||||
|
||||
async start({ localStream, remoteStream, remoteStreams, connectionId } = {}) {
|
||||
if (this.isRecording()) {
|
||||
throw new Error('会议正在录制中');
|
||||
}
|
||||
|
||||
if (!this.isSupported()) {
|
||||
throw new Error('当前浏览器不支持会议录制');
|
||||
}
|
||||
|
||||
const canvas = this.document.createElement('canvas');
|
||||
canvas.width = this.width;
|
||||
canvas.height = this.height;
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
if (!context) {
|
||||
throw new Error('无法创建录制画布');
|
||||
}
|
||||
|
||||
this.connectionId = connectionId || '';
|
||||
this.chunks = [];
|
||||
this.canvas = canvas;
|
||||
this.context = context;
|
||||
|
||||
const canvasStream = canvas.captureStream(this.fps);
|
||||
const streams = collectStreams({ localStream, remoteStream, remoteStreams });
|
||||
const audioTrack = this.createMixedAudioTrack(streams);
|
||||
const tracks = [
|
||||
...canvasStream.getVideoTracks(),
|
||||
...(audioTrack ? [audioTrack] : [])
|
||||
];
|
||||
|
||||
this.recordingStream = new this.window.MediaStream(tracks);
|
||||
try {
|
||||
this.startDrawing();
|
||||
this.startMediaRecorder(this.recordingStream);
|
||||
}
|
||||
catch (error) {
|
||||
this.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (!this.isRecording()) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pendingStop = { resolve, reject };
|
||||
this.mediaRecorder.stop();
|
||||
});
|
||||
}
|
||||
|
||||
createMixedAudioTrack(streams) {
|
||||
const audioTracks = collectLiveAudioTracks(streams);
|
||||
|
||||
if (audioTracks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const AudioContextCtor = this.window.AudioContext || this.window.webkitAudioContext;
|
||||
if (!AudioContextCtor) {
|
||||
return audioTracks[0].clone ? audioTracks[0].clone() : audioTracks[0];
|
||||
}
|
||||
|
||||
this.audioContext = new AudioContextCtor();
|
||||
const destination = this.audioContext.createMediaStreamDestination();
|
||||
|
||||
audioTracks.forEach(track => {
|
||||
const sourceStream = new this.window.MediaStream([track]);
|
||||
const source = this.audioContext.createMediaStreamSource(sourceStream);
|
||||
source.connect(destination);
|
||||
this.audioSources.push(source);
|
||||
});
|
||||
|
||||
return destination.stream.getAudioTracks()[0] || null;
|
||||
}
|
||||
|
||||
startMediaRecorder(stream) {
|
||||
const MediaRecorderCtor = this.window.MediaRecorder;
|
||||
const format = getSupportedFormat(MediaRecorderCtor);
|
||||
const options = format.mimeType ? { mimeType: format.mimeType } : {};
|
||||
this.fileExtension = format.extension;
|
||||
|
||||
this.mediaRecorder = new MediaRecorderCtor(stream, options);
|
||||
this.mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data && event.data.size > 0) {
|
||||
this.chunks.push(event.data);
|
||||
}
|
||||
};
|
||||
this.mediaRecorder.onerror = (event) => {
|
||||
if (this.pendingStop) {
|
||||
this.pendingStop.reject(event.error || new Error('录制失败'));
|
||||
this.pendingStop = null;
|
||||
}
|
||||
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';
|
||||
this.cleanup();
|
||||
if (this.pendingStop) {
|
||||
this.pendingStop.resolve({ blob, filename, mimeType });
|
||||
this.pendingStop = null;
|
||||
}
|
||||
};
|
||||
|
||||
this.mediaRecorder.start(1000);
|
||||
}
|
||||
|
||||
startDrawing() {
|
||||
const draw = () => {
|
||||
this.drawFrame();
|
||||
this.animationFrameId = this.window.requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
draw();
|
||||
}
|
||||
|
||||
drawFrame() {
|
||||
const context = this.context;
|
||||
const canvas = this.canvas;
|
||||
const videos = this.getRecordableVideos();
|
||||
const localVideo = videos.find(video => video.id === 'localVideo');
|
||||
const remoteVideos = videos.filter(video => video !== localVideo);
|
||||
|
||||
context.fillStyle = '#020617';
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
if (remoteVideos.length > 0) {
|
||||
drawGrid(context, remoteVideos, canvas);
|
||||
if (localVideo) {
|
||||
drawLocalPreview(context, localVideo, canvas);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (localVideo) {
|
||||
drawVideoCover(context, localVideo, 0, 0, canvas.width, canvas.height);
|
||||
return;
|
||||
}
|
||||
|
||||
drawEmptyFrame(context, canvas);
|
||||
}
|
||||
|
||||
getRecordableVideos() {
|
||||
return Array.from(this.document.querySelectorAll('#participantGrid video, #remoteVideo, #localVideo'))
|
||||
.filter(video => video.srcObject && isElementVisible(video) && video.readyState >= 2);
|
||||
}
|
||||
|
||||
download(blob, filename = this.buildFilename()) {
|
||||
const url = this.window.URL.createObjectURL(blob);
|
||||
const link = this.document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.style.display = 'none';
|
||||
this.document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
|
||||
this.window.setTimeout(() => {
|
||||
this.window.URL.revokeObjectURL(url);
|
||||
}, 1000);
|
||||
|
||||
return filename;
|
||||
}
|
||||
|
||||
buildFilename() {
|
||||
const datePart = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const meetingPart = this.connectionId ? `-${this.connectionId}` : '';
|
||||
return `meeting-recording${meetingPart}-${datePart}.${this.fileExtension || 'webm'}`;
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this.animationFrameId) {
|
||||
this.window.cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
|
||||
if (this.recordingStream) {
|
||||
this.recordingStream.getTracks().forEach(track => track.stop());
|
||||
this.recordingStream = null;
|
||||
}
|
||||
|
||||
if (this.audioContext) {
|
||||
this.audioContext.close();
|
||||
this.audioContext = null;
|
||||
}
|
||||
|
||||
this.audioSources = [];
|
||||
this.mediaRecorder = null;
|
||||
this.canvas = null;
|
||||
this.context = null;
|
||||
this.chunks = [];
|
||||
}
|
||||
}
|
||||
191
client/public/call/media/renderer-media.js
Normal file
191
client/public/call/media/renderer-media.js
Normal file
@@ -0,0 +1,191 @@
|
||||
import { createParticipantTile, getParticipantTile } from '../participants/renderer-participant-grid.js';
|
||||
import { createLogger } from '../../shared/logger.js';
|
||||
|
||||
const logger = createLogger('renderer-media');
|
||||
|
||||
export function getVideoResolution(track) {
|
||||
if (track && track.getSettings) {
|
||||
const settings = track.getSettings();
|
||||
return {
|
||||
width: settings.width || 640,
|
||||
height: settings.height || 480
|
||||
};
|
||||
}
|
||||
|
||||
return { width: 640, height: 480 };
|
||||
}
|
||||
|
||||
export function adjustVideoSize(videoElement) {
|
||||
if (!videoElement) return;
|
||||
|
||||
const container = videoElement.parentElement;
|
||||
if (!container) return;
|
||||
|
||||
videoElement.style.transform = 'translateZ(0)';
|
||||
videoElement.style.willChange = 'transform';
|
||||
container.style.display = 'flex';
|
||||
container.style.alignItems = 'center';
|
||||
container.style.justifyContent = 'center';
|
||||
videoElement.style.imageRendering = 'auto';
|
||||
videoElement.style.maxWidth = '100%';
|
||||
videoElement.style.maxHeight = '100%';
|
||||
videoElement.style.objectFit = 'contain';
|
||||
}
|
||||
|
||||
export function renderParticipantStreamMedia({
|
||||
grid,
|
||||
stream,
|
||||
connectionId,
|
||||
displayName,
|
||||
getGridTemplateColumns,
|
||||
remoteVideo,
|
||||
connectingOverlay,
|
||||
remoteVideoPlaceholder
|
||||
}) {
|
||||
if (!grid) return;
|
||||
|
||||
grid.classList.remove('hidden');
|
||||
|
||||
let tile = getParticipantTile(grid, connectionId);
|
||||
if (!tile) {
|
||||
tile = createParticipantTile(connectionId, displayName);
|
||||
grid.appendChild(tile);
|
||||
logger.debug(`Created participant video tile for ${connectionId}`);
|
||||
}
|
||||
|
||||
const video = tile.querySelector('video');
|
||||
if (video && stream) {
|
||||
if (video.srcObject === stream) {
|
||||
logger.debug(`Same stream for participant ${connectionId}, ensuring playback`);
|
||||
video.play().catch(error => logger.debug('Auto-play prevented:', error.message));
|
||||
} else {
|
||||
video.srcObject = stream;
|
||||
video.play().catch(error => logger.debug('Auto-play prevented:', error.message));
|
||||
logger.debug(`Set remote stream for participant tile ${connectionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
const remoteVideoContainer = remoteVideo?.closest('.absolute.inset-0.video-fade-in');
|
||||
if (remoteVideoContainer) {
|
||||
remoteVideoContainer.classList.add('hidden');
|
||||
}
|
||||
|
||||
const tileCount = grid.querySelectorAll('[data-participant-id]').length;
|
||||
grid.style.gridTemplateColumns = getGridTemplateColumns(tileCount);
|
||||
|
||||
if (connectingOverlay) {
|
||||
connectingOverlay.classList.add('hidden');
|
||||
}
|
||||
if (remoteVideoPlaceholder) {
|
||||
remoteVideoPlaceholder.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
export function renderSingleRemoteStreamMedia({
|
||||
remoteVideo,
|
||||
stream,
|
||||
disconnectedOverlay,
|
||||
remoteVideoPlaceholder,
|
||||
connectingOverlay
|
||||
}) {
|
||||
if (!remoteVideo || !stream) {
|
||||
logger.error('Either remoteVideo element or stream is missing');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('Rendering remote stream:', stream, 'tracks:', stream.getTracks().map(track => `${track.kind}(${track.readyState})`));
|
||||
|
||||
if (remoteVideo.srcObject === stream) {
|
||||
logger.debug('Same stream object, track added - ensuring playback');
|
||||
remoteVideo.play().catch(error => logger.debug('Auto-play prevented:', error.message));
|
||||
return;
|
||||
}
|
||||
|
||||
remoteVideo.srcObject = stream;
|
||||
remoteVideo.autoplay = true;
|
||||
remoteVideo.playsinline = true;
|
||||
remoteVideo.muted = false;
|
||||
remoteVideo.play().catch(error => {
|
||||
logger.debug('Auto-play prevented, will retry on interaction:', error.message);
|
||||
});
|
||||
|
||||
if (disconnectedOverlay) {
|
||||
disconnectedOverlay.classList.add('hidden');
|
||||
}
|
||||
|
||||
const videoTracks = stream.getVideoTracks();
|
||||
const audioTracks = stream.getAudioTracks();
|
||||
logger.debug(`Stream has ${videoTracks.length} video tracks, ${audioTracks.length} audio tracks`);
|
||||
|
||||
if (videoTracks.length === 0) {
|
||||
logger.debug('Audio-only stream, waiting for video track...');
|
||||
return;
|
||||
}
|
||||
|
||||
if (remoteVideoPlaceholder) {
|
||||
remoteVideoPlaceholder.classList.add('hidden');
|
||||
}
|
||||
if (connectingOverlay) {
|
||||
connectingOverlay.classList.add('hidden');
|
||||
}
|
||||
|
||||
const activeVideoTrack = videoTracks.find(track => track.readyState === 'live');
|
||||
if (!activeVideoTrack) return;
|
||||
|
||||
adjustVideoSize(remoteVideo, getVideoResolution(activeVideoTrack));
|
||||
activeVideoTrack.addEventListener('resize', () => {
|
||||
adjustVideoSize(remoteVideo, getVideoResolution(activeVideoTrack));
|
||||
});
|
||||
}
|
||||
|
||||
export function clearParticipantGrid(grid) {
|
||||
if (!grid) return;
|
||||
|
||||
grid.querySelectorAll('[data-participant-id]').forEach(tile => {
|
||||
const video = tile.querySelector('video');
|
||||
if (video) {
|
||||
video.srcObject = null;
|
||||
}
|
||||
tile.remove();
|
||||
});
|
||||
grid.classList.add('hidden');
|
||||
}
|
||||
|
||||
export function removeParticipantTile({
|
||||
grid,
|
||||
connectionId,
|
||||
getGridTemplateColumns,
|
||||
remoteVideo,
|
||||
remoteVideoPlaceholder,
|
||||
remoteNetworkIndicator
|
||||
}) {
|
||||
if (!grid) return;
|
||||
|
||||
const tile = getParticipantTile(grid, connectionId);
|
||||
if (tile) {
|
||||
const video = tile.querySelector('video');
|
||||
if (video) {
|
||||
video.srcObject = null;
|
||||
}
|
||||
tile.remove();
|
||||
logger.debug(`Removed participant video tile for ${connectionId}`);
|
||||
}
|
||||
|
||||
const remainingTiles = grid.querySelectorAll('[data-participant-id]');
|
||||
if (remainingTiles.length === 0) {
|
||||
grid.classList.add('hidden');
|
||||
const remoteVideoContainer = remoteVideo?.closest('.absolute.inset-0.video-fade-in');
|
||||
if (remoteVideoContainer) {
|
||||
remoteVideoContainer.classList.remove('hidden');
|
||||
}
|
||||
if (remoteVideoPlaceholder) {
|
||||
remoteVideoPlaceholder.classList.remove('hidden');
|
||||
}
|
||||
} else {
|
||||
grid.style.gridTemplateColumns = getGridTemplateColumns(remainingTiles.length);
|
||||
}
|
||||
|
||||
if (remoteNetworkIndicator) {
|
||||
remoteNetworkIndicator.className = 'w-2 h-2 bg-gray-500 rounded-full';
|
||||
}
|
||||
}
|
||||
96
client/public/call/media/webrtc-stats.js
Normal file
96
client/public/call/media/webrtc-stats.js
Normal file
@@ -0,0 +1,96 @@
|
||||
function createStatsSummary() {
|
||||
return {
|
||||
video: {
|
||||
packetsLost: 0,
|
||||
packetsReceived: 0,
|
||||
bytesReceived: 0,
|
||||
jitter: 0,
|
||||
roundTripTime: 0,
|
||||
fps: 0,
|
||||
bitrate: 0
|
||||
},
|
||||
audio: {
|
||||
packetsLost: 0,
|
||||
packetsReceived: 0,
|
||||
bytesReceived: 0,
|
||||
jitter: 0
|
||||
},
|
||||
network: {
|
||||
totalPacketsLost: 0,
|
||||
totalPacketsReceived: 0,
|
||||
inboundRtpCount: 0,
|
||||
jitter: 0,
|
||||
roundTripTime: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeInboundStats(stats) {
|
||||
const summary = createStatsSummary();
|
||||
|
||||
stats.forEach((report) => {
|
||||
if (report.type === 'inbound-rtp' && report.mediaType === 'video') {
|
||||
summary.network.inboundRtpCount++;
|
||||
|
||||
if (report.packetsLost !== undefined && report.packetsReceived !== undefined) {
|
||||
summary.network.totalPacketsLost += report.packetsLost;
|
||||
summary.network.totalPacketsReceived += report.packetsReceived;
|
||||
}
|
||||
|
||||
if (report.jitter !== undefined) {
|
||||
summary.network.jitter = Math.max(summary.network.jitter, report.jitter);
|
||||
}
|
||||
|
||||
if (report.roundTripTime !== undefined) {
|
||||
summary.network.roundTripTime = Math.max(summary.network.roundTripTime, report.roundTripTime);
|
||||
}
|
||||
|
||||
summary.video.packetsLost = report.packetsLost || 0;
|
||||
summary.video.packetsReceived = report.packetsReceived || 0;
|
||||
summary.video.bytesReceived = report.bytesReceived || 0;
|
||||
summary.video.jitter = report.jitter || 0;
|
||||
summary.video.roundTripTime = report.roundTripTime || 0;
|
||||
summary.video.fps = report.framesPerSecond || 0;
|
||||
|
||||
if (report.bytesReceived && report.timestamp) {
|
||||
const duration = report.timestamp / 1000;
|
||||
summary.video.bitrate = duration > 0
|
||||
? Math.round((report.bytesReceived * 8) / (duration * 1000))
|
||||
: 0;
|
||||
}
|
||||
} else if (report.type === 'inbound-rtp' && report.mediaType === 'audio') {
|
||||
summary.audio.packetsLost = report.packetsLost || 0;
|
||||
summary.audio.packetsReceived = report.packetsReceived || 0;
|
||||
summary.audio.bytesReceived = report.bytesReceived || 0;
|
||||
summary.audio.jitter = report.jitter || 0;
|
||||
}
|
||||
});
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
export function getNetworkQualityFromSummary(summary) {
|
||||
const { totalPacketsLost, totalPacketsReceived, inboundRtpCount, jitter, roundTripTime } = summary.network;
|
||||
|
||||
if (inboundRtpCount === 0) {
|
||||
return 'no_signal';
|
||||
}
|
||||
|
||||
const packetLossRate = totalPacketsReceived > 0
|
||||
? totalPacketsLost / (totalPacketsLost + totalPacketsReceived)
|
||||
: 0;
|
||||
const jitterMs = jitter * 1000;
|
||||
const rttMs = roundTripTime * 1000;
|
||||
|
||||
if (packetLossRate > 0.05 || jitterMs > 100 || rttMs > 300) {
|
||||
return 'poor';
|
||||
}
|
||||
if (packetLossRate > 0.02 || jitterMs > 50 || rttMs > 150) {
|
||||
return 'fair';
|
||||
}
|
||||
if (packetLossRate > 0.01 || jitterMs > 30 || rttMs > 100) {
|
||||
return 'good';
|
||||
}
|
||||
|
||||
return 'excellent';
|
||||
}
|
||||
Reference in New Issue
Block a user