+
${session.deviceId}
+
${duration} • ${session.camera}
+
+
- Камеры: ${(Array.isArray(device.capabilities) ? device.capabilities.join(', ') : (device.capabilities?.cameras?.join(', ') || ''))}
+ 📷 ${(Array.isArray(device.capabilities) ? device.capabilities.join(', ') : (device.capabilities?.cameras?.join(', ') || 'back'))}
+ ${session.status === 'active' ? `
+
+
+ ${session.connectionId ? `
+
+ ` : `
+
+ `}
+ ` : `
+
+ `}
+
+ `;
+
+ container.appendChild(sessionElement);
+ });
}
+ switchToSession(sessionId) {
+ const session = this.activeSessions.get(sessionId);
+ if (!session || session.status !== 'active') {
+ this.log(`Нельзя переключиться на сессию ${sessionId}`, 'error');
+ return;
+ }
+
+ // Если это уже активная сессия, ничего не делаем
+ if (this.currentActiveSession === sessionId) {
+ this.log('Эта сессия уже активна', 'info');
+ return;
+ }
+
+ this.currentActiveSession = sessionId;
+
+ // Обновляем currentSession для обратной совместимости
+ this.currentSession = {
+ id: sessionId,
+ deviceId: session.deviceId,
+ cameraType: session.cameraType
+ };
+
+ this.log(`Переключение на сессию: ${sessionId} (устройство: ${session.deviceId})`, 'info');
+ this.elements.sessionInfo.textContent = `Активная сессия: ${session.deviceId} (${session.cameraType})`;
+
+ // Обновляем кнопки камеры
+ this.updateCameraButtons(session.cameraType);
+
+ // Обновляем список сессий
+ this.updateSessionsList();
+
+ // TODO: Переключить видеопоток
+ // В будущем здесь будет логика переключения WebRTC потоков
+ }
+
+ switchCamera(sessionIdOrType, cameraType) {
+ // Определяем, передан sessionId или это старый вызов
+ let sessionId, targetCameraType;
+
+ if (cameraType) {
+ // Новый вызов: switchCamera(sessionId, cameraType)
+ sessionId = sessionIdOrType;
+ targetCameraType = cameraType;
+ } else {
+ // Старый вызов: switchCamera(cameraType)
+ targetCameraType = sessionIdOrType;
+ sessionId = this.currentActiveSession;
+
+ if (!sessionId && this.currentSession) {
+ // Обратная совместимость со старым кодом
+ sessionId = this.currentSession.id;
+ }
+ }
+
+ if (!sessionId) {
+ this.log('Нет активной сессии для переключения камеры', 'warning');
+ this.showToast('Переключение камеры', 'Нет активной сессии для переключения камеры', 'warning');
+ return;
+ }
+
+ const session = this.activeSessions.get(sessionId);
+ if (!session || session.status !== 'active') {
+ this.log(`Нельзя переключить камеру в сессии ${sessionId}`, 'error');
+ this.showToast('Ошибка переключения', `Нельзя переключить камеру в сессии ${sessionId}`, 'error');
+ return;
+ }
+
+ this.log(`Переключение на камеру: ${targetCameraType} в сессии ${sessionId}`, 'info');
+ this.showToast('Переключение камеры', `Переключение на камеру: ${targetCameraType}`, 'info');
+
+ if (this.socket && this.socket.connected) {
+ this.socket.emit('camera:switch', {
+ sessionId: sessionId,
+ cameraType: targetCameraType
+ });
+
+ // Обновляем информацию о сессии локально
+ session.cameraType = targetCameraType;
+ this.updateSessionsList();
+ this.updateCameraButtons(targetCameraType);
+
+ if (this.currentActiveSession === sessionId) {
+ this.elements.sessionInfo.textContent = `Активная сессия: ${session.deviceId} (${targetCameraType})`;
+ }
+ } else {
+ this.log('Нет подключения к серверу для переключения камеры', 'error');
+ this.showToast('Ошибка подключения', 'Нет подключения к серверу', 'error');
+ }
+ }
+
+ toggleCamera(sessionId) {
+ const session = this.activeSessions.get(sessionId);
+ if (!session || session.status !== 'active') {
+ this.log(`Нельзя переключить камеру в сессии ${sessionId}`, 'error');
+ return;
+ }
+
+ // Переключаем между back и front
+ const newCameraType = session.cameraType === 'back' ? 'front' : 'back';
+ this.switchCamera(sessionId, newCameraType);
+ }
+
+
+
requestCamera(deviceId, cameraType = 'back') {
if (!this.socket || !this.socket.connected) {
this.log('Нет подключения к серверу', 'error');
@@ -466,6 +1826,7 @@ class GodEyeOperator {
this.log(`Запрос доступа к камере ${cameraType} устройства ${deviceId}`, 'info');
+ // Используем новое событие для подключения через ConnectionManager
this.socket.emit('camera:request', {
deviceId: deviceId,
operatorId: this.operatorId,
@@ -474,12 +1835,40 @@ class GodEyeOperator {
}
handleCameraResponse(data) {
+ this.log(`Получен ответ camera:response: ${JSON.stringify(data)}`, 'info');
+
if (data.success) {
- this.currentSession = data.session;
- this.elements.sessionInfo.textContent = `Сессия: ${data.session.id}`;
- this.log(`Доступ к камере получен. Сессия: ${data.session.id}`, 'success');
- this.updateCameraButtons(data.session.cameraType);
- this.initWebRTC();
+ this.log('Доступ к камере получен успешно', 'success');
+
+ // Проверяем структуру данных
+ if (!data.session) {
+ this.log('Ошибка: отсутствует data.session в ответе', 'error');
+ return;
+ }
+
+ // Обновляем сессию в коллекции
+ const sessionData = {
+ sessionId: data.sessionId || data.session.id,
+ deviceId: data.session.deviceId,
+ cameraType: data.session.cameraType,
+ status: 'active'
+ };
+
+ this.log(`Создаем сессию: ${JSON.stringify(sessionData)}`, 'info');
+ this.activeSessions.set(sessionData.sessionId, sessionData);
+
+ // Если нет текущей активной сессии, сделаем эту активной
+ if (!this.currentActiveSession) {
+ this.log(`Устанавливаем активную сессию: ${sessionData.sessionId}`, 'info');
+ this.currentActiveSession = sessionData.sessionId;
+ this.currentSession = data.session; // Сохраняем для обратной совместимости
+ this.elements.sessionInfo.textContent = `Активная сессия: ${sessionData.deviceId} (${sessionData.cameraType})`;
+ this.updateCameraButtons(sessionData.cameraType);
+ this.initWebRTC();
+ }
+
+ this.log(`Доступ к камере получен. Сессия: ${sessionData.sessionId}`, 'success');
+ this.updateSessionsList();
} else {
this.log(`Отказ в доступе к камере: ${data.message}`, 'error');
}
@@ -494,37 +1883,459 @@ class GodEyeOperator {
});
}
- switchCamera(cameraType) {
- if (!this.currentSession) {
- this.log('Нет активной сессии для переключения камеры', 'warning');
+ // Новые методы для управления подключениями через ConnectionManager
+
+ terminateConnection(connectionId, sessionId) {
+ if (!this.socket || !this.socket.connected) {
+ this.log('Нет подключения к серверу', 'error');
return;
}
- this.log(`Переключение на камеру: ${cameraType}`, 'info');
+ this.log(`Завершение подключения: ${connectionId}`, 'info');
- this.socket.emit('camera:switch', {
- sessionId: this.currentSession.id,
- cameraType: cameraType
+ this.socket.emit('connection:terminate', {
+ connectionId: connectionId
});
- this.updateCameraButtons(cameraType);
+ // Локально обновляем состояние сессии
+ const session = this.activeSessions.get(sessionId);
+ if (session) {
+ session.status = 'terminating';
+ this.updateSessionsList();
+ }
+ }
+
+ getConnectionStatus() {
+ if (!this.socket || !this.socket.connected) {
+ this.log('Нет подключения к серверу', 'error');
+ return;
+ }
+
+ this.socket.emit('connection:status', {}, (response) => {
+ if (response.success) {
+ this.log('Статистика подключений получена', 'info');
+ console.log('Connection Stats:', response.stats);
+ } else {
+ this.log(`Ошибка получения статистики: ${response.error}`, 'error');
+ }
+ });
+ }
+
+ listMyConnections() {
+ if (!this.socket || !this.socket.connected) {
+ this.log('Нет подключения к серверу', 'error');
+ return;
+ }
+
+ this.socket.emit('connection:list', {}, (response) => {
+ if (response.success) {
+ this.log(`Получен список подключений: ${response.connections.length}`, 'info');
+ console.log('My Connections:', response.connections);
+
+ // Можно обновить UI с информацией о подключениях
+ response.connections.forEach(conn => {
+ console.log(`Connection ${conn.connectionId}: ${conn.deviceId} -> ${conn.status}`);
+ });
+ } else {
+ this.log(`Ошибка получения списка подключений: ${response.error}`, 'error');
+ }
+ });
}
async initWebRTC() {
try {
+ // Сбрасываем состояние WebRTC для новой сессии
+ this.pendingICECandidates = [];
+ this.isRemoteDescriptionSet = false;
+ this.isVideoStreamProcessed = false; // Сброс флага обработки видео
+ this.isProcessingOffer = false; // Сброс флага обработки offer
+
+ // Запускаем мониторинг статистики
+ this.startStatsMonitoring();
+
this.localConnection = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
});
+ // Handle connection state changes
+ this.localConnection.onconnectionstatechange = () => {
+ const state = this.localConnection.connectionState;
+ this.log(`WebRTC connection state: ${state}`, state === 'connected' ? 'success' : 'info');
+ console.log('WebRTC connection state changed:', state);
+ };
+
+ this.localConnection.oniceconnectionstatechange = () => {
+ const state = this.localConnection.iceConnectionState;
+ this.log(`ICE connection state: ${state}`, state === 'connected' || state === 'completed' ? 'success' : 'info');
+ console.log('ICE connection state changed:', state);
+
+ // Принудительно скрываем overlay при успешном соединении
+ if (state === 'connected' || state === 'completed') {
+ console.log('🔥 ICE connected - forcing video overlay to hide');
+ this.elements.videoOverlay.classList.add('hidden');
+
+ // Дополнительная проверка video элемента
+ const videoElement = this.elements.remoteVideo;
+ console.log('📺 Video element check on ICE connected:');
+ console.log(' Video element exists:', !!videoElement);
+ console.log(' SrcObject exists:', !!videoElement.srcObject);
+ console.log(' Video dimensions:', videoElement.videoWidth, 'x', videoElement.videoHeight);
+ console.log(' Video paused:', videoElement.paused);
+ console.log(' Video ready state:', videoElement.readyState);
+
+ // Проверяем статус video track
+ const videoTrack = videoElement.srcObject?.getVideoTracks()?.[0];
+ if (videoTrack) {
+ console.log(' Video track muted:', videoTrack.muted);
+ console.log(' Video track readyState:', videoTrack.readyState);
+ }
+
+ // Попытка принудительно запустить видео, если оно не играет
+ if (videoElement.paused && videoElement.srcObject) {
+ console.log('🔥 Forcing video play on ICE connected');
+ videoElement.play().catch(error => {
+ console.error('Failed to play video on ICE connected:', error);
+ });
+ }
+
+ // Через 3 секунды принудительно скрываем overlay даже если видео не готово
+ setTimeout(() => {
+ console.log('⏰ Forcing overlay hide after 3 seconds timeout');
+ this.elements.videoOverlay.classList.add('hidden');
+ // Дополнительная попытка запуска видео
+ this.forceVideoPlayback();
+ }, 3000);
+ }
+ };
+
+ this.localConnection.onicegatheringstatechange = () => {
+ const state = this.localConnection.iceGatheringState;
+ this.log(`ICE gathering state: ${state}`, 'info');
+ console.log('ICE gathering state changed:', state);
+ };
+
+ this.localConnection.onsignalingstatechange = () => {
+ const state = this.localConnection.signalingState;
+ this.log(`Signaling state: ${state}`, 'info');
+ console.log('Signaling state changed:', state);
+ };
+
// Handle incoming stream
this.localConnection.ontrack = (event) => {
+ console.log('🎯 ontrack event triggered!');
+ console.log(' Event object:', event);
+ console.log(' Streams length:', event.streams?.length);
+
this.log('Получен видеопоток', 'success');
+ console.log('ontrack event:', event);
+ console.log('streams:', event.streams);
+ console.log('tracks:', event.streams[0]?.getTracks());
+
this.remoteStream = event.streams[0];
- this.elements.remoteVideo.srcObject = this.remoteStream;
+ const videoElement = this.elements.remoteVideo;
+
+ // Принудительно скрываем overlay сразу при получении потока
this.elements.videoOverlay.classList.add('hidden');
+ // Детальная диагностика потока
+ console.log('📺 Video stream analysis:');
+ console.log(' Stream object:', this.remoteStream);
+ console.log(' Stream ID:', this.remoteStream.id);
+ console.log(' Stream active:', this.remoteStream.active);
+ console.log(' Video tracks:', this.remoteStream.getVideoTracks());
+ console.log(' Audio tracks:', this.remoteStream.getAudioTracks());
+
+ const videoTracks = this.remoteStream.getVideoTracks();
+ if (videoTracks.length > 0) {
+ const track = videoTracks[0];
+ console.log(' Video track details:');
+ console.log(' ID:', track.id);
+ console.log(' Kind:', track.kind);
+ console.log(' Enabled:', track.enabled);
+ console.log(' Muted:', track.muted);
+ console.log(' ReadyState:', track.readyState);
+ console.log(' Settings:', track.getSettings());
+
+ // Принудительно размьючиваем трек если он заглушен
+ if (track.muted) {
+ console.log('🔧 FORCING track unmute - trying to enable video data');
+ // Попытка принудительного размьютинга (может не работать для удаленных треков)
+ try {
+ track.enabled = true;
+ console.log('✅ Track enabled set to true');
+ } catch (e) {
+ console.log('⚠️ Cannot control remote track enabled state:', e.message);
+ }
+ }
+
+ // Принудительно включаем заглушенный трек
+ if (track.muted) {
+ console.log('🔧 Принудительно пытаемся разблокировать muted video track');
+ // Попробуем клонировать трек для обхода muted состояния
+ try {
+ const clonedTrack = track.clone();
+ if (clonedTrack && !clonedTrack.muted) {
+ console.log('✅ Создан клон трека без mute');
+ const newStream = new MediaStream([clonedTrack]);
+ this.remoteStream.getAudioTracks().forEach(audioTrack => {
+ newStream.addTrack(audioTrack);
+ });
+ this.remoteStream = newStream;
+ videoElement.srcObject = newStream;
+ }
+ } catch (error) {
+ console.warn('Не удалось клонировать трек:', error);
+ }
+ }
+
+ // Слушаем события unmute на треке
+ track.addEventListener('unmute', () => {
+ console.log('🔊 Video track unmuted - video data should be available now!');
+ this.elements.videoOverlay.classList.add('hidden');
+
+ // Принудительно пытаемся запустить видео при unmute
+ if (videoElement.paused) {
+ console.log('🎬 Starting video playback after unmute');
+ videoElement.play().catch(error => {
+ console.error('Failed to play video after unmute:', error);
+ });
+ }
+ });
+
+ track.addEventListener('mute', () => {
+ console.log('🔇 Video track muted');
+ });
+
+ // Скрываем overlay независимо от состояния mute
+ console.log('� Hiding overlay regardless of track mute state');
+ this.elements.videoOverlay.classList.add('hidden');
+
+ // Добавляем более агрессивный timeout для unmute
+ setTimeout(() => {
+ console.log('⏰ Aggressive unmute attempt after 2 seconds');
+ if (track.muted) {
+ console.log('🔧 Track still muted, forcing playback anyway');
+ }
+ this.forceVideoPlayback();
+ }, 2000);
+ } else {
+ console.error('❌ No video tracks found in stream!');
+ this.log('Нет видеотреков в потоке!', 'error');
+ // Даже без видеотреков скрываем overlay через 3 секунды и пытаемся запустить
+ setTimeout(() => {
+ this.elements.videoOverlay.classList.add('hidden');
+ this.forceVideoPlayback();
+ }, 3000);
+ }
+
+ // Устанавливаем поток
+ videoElement.srcObject = this.remoteStream;
+ console.log('📺 Video element updated with stream');
+
+ // Запускаем мониторинг видео
+ this.startVideoMonitoring();
+
+ // Принудительно запускаем видео сразу
+ setTimeout(() => {
+ console.log('🚀 Force starting video playback');
+ this.forceVideoPlayback();
+
+ // Дополнительная попытка через 1 секунду
+ setTimeout(() => {
+ if (videoElement.paused || videoElement.videoWidth === 0) {
+ console.log('🔄 Second attempt to start video');
+ this.forceVideoPlayback();
+ }
+ }, 1000);
+ }, 100);
+
+ // Защита от прерывания - ждем готовности элемента
+ const waitForVideoReady = () => {
+ return new Promise((resolve) => {
+ // Проверяем наличие видеоданных - не просто загрузку
+ const checkVideoData = () => {
+ console.log('📺 Checking video data readiness:');
+ console.log(' Ready state:', videoElement.readyState);
+ console.log(' Video dimensions:', videoElement.videoWidth, 'x', videoElement.videoHeight);
+ console.log(' Network state:', videoElement.networkState);
+
+ // Видео готово, если есть размеры И данные загружены
+ if (videoElement.readyState >= 2 && videoElement.videoWidth > 0 && videoElement.videoHeight > 0) {
+ console.log('✅ Video data is ready!');
+ resolve();
+ } else if (videoElement.readyState >= 2) {
+ console.log('⏳ Video metadata loaded but no dimensions yet, waiting...');
+ // Если метаданные загружены но размеров нет, ждем еще
+ setTimeout(checkVideoData, 200);
+ } else {
+ console.log('⏳ Video not ready yet, waiting for loadeddata...');
+ // Также проверяем unmute статус трека
+ const videoTrack = videoElement.srcObject?.getVideoTracks()?.[0];
+ if (videoTrack && !videoTrack.muted) {
+ console.log('🔊 Video track is unmuted, but element not ready yet');
+ setTimeout(checkVideoData, 500);
+ } else {
+ // Ждем события loadeddata
+ videoElement.addEventListener('loadeddata', checkVideoData, { once: true });
+ }
+ }
+ };
+
+ checkVideoData();
+ });
+ };
+
+ // Добавляем обработчики событий видео
+ videoElement.onloadedmetadata = () => {
+ this.log('Видео метаданные загружены', 'success');
+ console.log('📺 Video metadata loaded:');
+ console.log(' Dimensions:', videoElement.videoWidth, 'x', videoElement.videoHeight);
+ console.log(' Duration:', videoElement.duration);
+ console.log(' ReadyState:', videoElement.readyState);
+ console.log(' NetworkState:', videoElement.networkState);
+ console.log(' Paused:', videoElement.paused);
+ console.log(' Ended:', videoElement.ended);
+ };
+
+ videoElement.onplay = () => {
+ this.log('Видео началось', 'success');
+ console.log('📺 Video started playing');
+ console.log(' Current time:', videoElement.currentTime);
+ console.log(' Video dimensions:', videoElement.videoWidth, 'x', videoElement.videoHeight);
+ };
+
+ videoElement.onplaying = () => {
+ console.log('📺 Video is playing (after buffering)');
+ };
+
+ videoElement.oncanplay = () => {
+ console.log('📺 Video can start playing');
+ // Когда видео готово к воспроизведению, скрываем overlay
+ this.elements.videoOverlay.classList.add('hidden');
+ // Принудительно запускаем видео
+ if (videoElement.paused) {
+ videoElement.play().catch(error => {
+ console.error('Failed to auto-play video on canplay:', error);
+ });
+ }
+ };
+
+ videoElement.oncanplaythrough = () => {
+ console.log('📺 Video can play through without interruption');
+ };
+
+ videoElement.onloadstart = () => {
+ console.log('📺 Video load started');
+ };
+
+ videoElement.onloadeddata = () => {
+ console.log('📺 Video data loaded');
+ // При загрузке данных проверяем, можно ли скрыть overlay
+ if (videoElement.videoWidth > 0 && videoElement.videoHeight > 0) {
+ console.log('📺 Video has dimensions, hiding overlay');
+ this.elements.videoOverlay.classList.add('hidden');
+ }
+ };
+
+ videoElement.onerror = (error) => {
+ this.log(`Ошибка видео: ${error}`, 'error');
+ console.error('❌ Video error:', error);
+ console.error(' Error code:', videoElement.error?.code);
+ console.error(' Error message:', videoElement.error?.message);
+ };
+
+ videoElement.onstalled = () => {
+ console.warn('⚠️ Video stalled');
+ };
+
+ videoElement.onsuspend = () => {
+ console.warn('⚠️ Video suspended');
+ };
+
+ videoElement.onwaiting = () => {
+ console.warn('⚠️ Video waiting for data');
+ };
+
+ // Отслеживаем изменение размеров видео
+ videoElement.onresize = () => {
+ console.log('📺 Video dimensions changed:', videoElement.videoWidth, 'x', videoElement.videoHeight);
+ if (videoElement.videoWidth > 0 && videoElement.videoHeight > 0) {
+ console.log('📺 Video has valid dimensions, ensuring overlay is hidden');
+ this.elements.videoOverlay.classList.add('hidden');
+ }
+ };
+
+ // Принудительно запускаем воспроизведение
+ console.log('📺 Attempting to play video...');
+ console.log(' Autoplay:', videoElement.autoplay);
+ console.log(' Muted:', videoElement.muted);
+ console.log(' Controls:', videoElement.controls);
+ console.log(' SrcObject exists:', !!videoElement.srcObject);
+
+ // Устанавливаем атрибуты для автоплея
+ videoElement.autoplay = true;
+ videoElement.muted = true;
+ videoElement.controls = true;
+ videoElement.playsInline = true;
+
+ // Асинхронно запускаем воспроизведение после готовности
+ waitForVideoReady().then(() => {
+ console.log('📺 Video ready, starting playback...');
+ return videoElement.play();
+ }).then(() => {
+ this.log('Видео запущено принудительно', 'success');
+ console.log('✅ Video play() succeeded');
+ this.elements.videoOverlay.classList.add('hidden');
+
+ // Проверяем статус через 2 секунды
+ setTimeout(() => {
+ console.log('📺 Video status check after 2 seconds:');
+ console.log(' Playing:', !videoElement.paused && !videoElement.ended);
+ console.log(' Paused:', videoElement.paused);
+ console.log(' Ended:', videoElement.ended);
+ console.log(' Current time:', videoElement.currentTime);
+ console.log(' Ready state:', videoElement.readyState);
+ console.log(' Network state:', videoElement.networkState);
+ console.log(' Video dimensions:', videoElement.videoWidth, 'x', videoElement.videoHeight);
+
+ // Дополнительная диагностика стилей и DOM
+ console.log('📺 Video element DOM info:');
+ console.log(' Element exists:', !!videoElement);
+ console.log(' Display style:', window.getComputedStyle(videoElement).display);
+ console.log(' Visibility style:', window.getComputedStyle(videoElement).visibility);
+ console.log(' Opacity style:', window.getComputedStyle(videoElement).opacity);
+ console.log(' Width style:', window.getComputedStyle(videoElement).width);
+ console.log(' Height style:', window.getComputedStyle(videoElement).height);
+ console.log(' Position style:', window.getComputedStyle(videoElement).position);
+ console.log(' Overlay hidden:', this.elements.videoOverlay.classList.contains('hidden'));
+
+ if (videoElement.videoWidth === 0 || videoElement.videoHeight === 0) {
+ console.error('❌ Video has no dimensions - likely no video data');
+ this.log('Видео не имеет размеров - возможно нет видеоданных', 'error');
+ }
+
+ if (videoElement.paused) {
+ console.warn('⚠️ Video is still paused, trying to play again');
+ videoElement.play();
+ }
+ }, 2000);
+ }).catch(error => {
+ this.log(`Ошибка запуска видео: ${error.message}`, 'error');
+ console.error('❌ Play error:', error);
+ console.error(' Error name:', error.name);
+ console.error(' Error message:', error.message);
+
+ // Попробуем еще раз через секунду
+ setTimeout(() => {
+ console.log('🔄 Retrying video play...');
+ videoElement.play().catch(retryError => {
+ console.error('❌ Retry failed:', retryError);
+ });
+ }, 1000);
+ });
+
// Enable recording controls when stream is available
this.elements.startRecording.disabled = false;
};
@@ -539,38 +2350,190 @@ class GodEyeOperator {
}
};
- // Create and send offer
- const offer = await this.localConnection.createOffer();
- await this.localConnection.setLocalDescription(offer);
-
- this.socket.emit('webrtc:offer', {
- sessionId: this.currentSession.id,
- offer: offer
- });
+ // Desktop-operator НЕ создает offer!
+ // Он только отвечает на offer'ы от Android устройств
+ this.log('WebRTC инициализирован, ожидаем offer от Android устройства', 'info');
} catch (error) {
this.log(`Ошибка инициализации WebRTC: ${error.message}`, 'error');
}
}
+ cleanupWebRTC() {
+ try {
+ this.log('Очистка WebRTC соединения', 'info');
+
+ // Останавливаем мониторинг
+ this.stopVideoMonitoring();
+ if (this.statsInterval) {
+ clearInterval(this.statsInterval);
+ this.statsInterval = null;
+ }
+
+ // Очищаем video element
+ if (this.elements.remoteVideo) {
+ this.elements.remoteVideo.srcObject = null;
+ this.elements.remoteVideo.load(); // Сброс состояния video element
+
+ // Показываем overlay обратно
+ if (this.elements.videoOverlay) {
+ this.elements.videoOverlay.classList.remove('hidden');
+ this.elements.videoOverlay.style.display = 'flex';
+ }
+ }
+
+ // Закрываем RTCPeerConnection
+ if (this.localConnection) {
+ this.localConnection.close();
+ this.localConnection = null;
+ }
+
+ // Очищаем remote stream
+ if (this.remoteStream) {
+ this.remoteStream.getTracks().forEach(track => {
+ track.stop();
+ });
+ this.remoteStream = null;
+ }
+
+ // Сбрасываем флаги состояния
+ this.isRemoteDescriptionSet = false;
+ this.isVideoStreamProcessed = false;
+ this.isProcessingOffer = false;
+ this.pendingICECandidates = [];
+
+ // Отключаем запись если активна
+ if (this.isRecording) {
+ this.stopRecording();
+ }
+
+ // Очищаем UI
+ this.elements.sessionInfo.textContent = '';
+ this.updateVideoStats({ bitrate: 0, fps: 0, packetsLost: 0, resolution: '0x0' });
+
+ this.log('WebRTC соединение очищено', 'success');
+
+ } catch (error) {
+ this.log(`Ошибка очистки WebRTC: ${error.message}`, 'error');
+ }
+ }
+
async handleWebRTCOffer(data) {
try {
- await this.localConnection.setRemoteDescription(data.offer);
- const answer = await this.localConnection.createAnswer();
- await this.localConnection.setLocalDescription(answer);
+ console.log('Received WebRTC offer:', data);
- this.socket.emit('webrtc:answer', {
- sessionId: data.sessionId,
- answer: answer
- });
+ // Проверяем существование localConnection
+ if (!this.localConnection) {
+ this.log('WebRTC соединение не инициализировано, создаем новое', 'warning');
+ await this.initWebRTC();
+
+ if (!this.localConnection) {
+ this.log('Ошибка: не удалось создать WebRTC соединение', 'error');
+ return;
+ }
+ }
+
+ // Проверяем, не обрабатывается ли уже offer
+ if (this.isProcessingOffer) {
+ this.log('Offer уже обрабатывается, пропускаем дубликат', 'warning');
+ return;
+ }
+
+ // Проверяем состояние соединения для избежания ошибки "wrong state"
+ if (this.localConnection.signalingState !== 'stable' && this.localConnection.signalingState !== 'have-local-offer') {
+ this.log(`Пропускаем offer, неподходящее состояние: ${this.localConnection.signalingState}`, 'warning');
+ return;
+ }
+
+ // Устанавливаем флаг обработки
+ this.isProcessingOffer = true;
+
+ // Валидация и нормализация offer
+ let offer = data.offer;
+
+ // Проверяем, что offer имеет правильную структуру
+ if (!offer || typeof offer !== 'object') {
+ throw new Error('Invalid offer: not an object');
+ }
+
+ if (!offer.type || !offer.sdp) {
+ throw new Error('Invalid offer: missing type or sdp');
+ }
+
+ // Убеждаемся, что offer имеет правильный формат RTCSessionDescriptionInit
+ const normalizedOffer = {
+ type: offer.type,
+ sdp: offer.sdp
+ };
+
+ console.log('Normalized offer:', normalizedOffer);
+ console.log('Offer SDP содержит видео:', normalizedOffer.sdp.includes('m=video'));
+ console.log('Offer SDP содержит аудио:', normalizedOffer.sdp.includes('m=audio'));
+
+ await this.localConnection.setRemoteDescription(normalizedOffer);
+ this.isRemoteDescriptionSet = true;
+
+ // Обрабатываем буферизованные ICE кандидаты
+ this.log(`Обрабатываем ${this.pendingICECandidates.length} буферизованных ICE кандидатов`, 'info');
+ for (const candidateData of this.pendingICECandidates) {
+ try {
+ await this.localConnection.addIceCandidate(candidateData);
+ this.log('Буферизованный ICE candidate добавлен', 'success');
+ } catch (error) {
+ this.log(`Ошибка добавления буферизованного ICE candidate: ${error.message}`, 'error');
+ }
+ }
+ this.pendingICECandidates = []; // Очищаем буфер
+
+ // Проверяем, что мы еще можем создать answer
+ if (this.localConnection.signalingState === 'have-remote-offer') {
+ const answer = await this.localConnection.createAnswer();
+ await this.localConnection.setLocalDescription(answer);
+
+ this.socket.emit('webrtc:answer', {
+ sessionId: data.sessionId,
+ answer: answer
+ });
+
+ this.log('WebRTC offer обработан успешно', 'success');
+ } else {
+ this.log(`Не можем создать answer, состояние: ${this.localConnection.signalingState}`, 'warning');
+ }
} catch (error) {
this.log(`Ошибка обработки WebRTC offer: ${error.message}`, 'error');
+ console.error('WebRTC offer error details:', error, 'Received data:', data);
+ } finally {
+ // Сбрасываем флаг обработки
+ this.isProcessingOffer = false;
}
}
async handleWebRTCAnswer(data) {
try {
- await this.localConnection.setRemoteDescription(data.answer);
+ console.log('Received WebRTC answer:', data);
+
+ // Валидация и нормализация answer
+ let answer = data.answer;
+
+ // Проверяем, что answer имеет правильную структуру
+ if (!answer || typeof answer !== 'object') {
+ throw new Error('Invalid answer: not an object');
+ }
+
+ if (!answer.type || !answer.sdp) {
+ throw new Error('Invalid answer: missing type or sdp');
+ }
+
+ // Убеждаемся, что answer имеет правильный формат RTCSessionDescriptionInit
+ const normalizedAnswer = {
+ type: answer.type,
+ sdp: answer.sdp
+ };
+
+ console.log('Normalized answer:', normalizedAnswer);
+
+ await this.localConnection.setRemoteDescription(normalizedAnswer);
+ this.log('WebRTC answer обработан успешно', 'success');
} catch (error) {
this.log(`Ошибка обработки WebRTC answer: ${error.message}`, 'error');
}
@@ -578,9 +2541,54 @@ class GodEyeOperator {
async handleICECandidate(data) {
try {
- await this.localConnection.addIceCandidate(data.candidate);
+ console.log('Received ICE candidate:', data);
+
+ // Валидация и нормализация ICE candidate
+ let candidate = data.candidate;
+
+ // Проверяем, что candidate имеет правильную структуру
+ if (!candidate) {
+ // Null candidate означает конец ICE gathering
+ if (this.isRemoteDescriptionSet) {
+ await this.localConnection.addIceCandidate(null);
+ this.log('ICE gathering завершен', 'info');
+ } else {
+ this.log('ICE gathering завершен (буферизован)', 'info');
+ this.pendingICECandidates.push(null);
+ }
+ return;
+ }
+
+ if (typeof candidate !== 'object') {
+ throw new Error('Invalid candidate: not an object');
+ }
+
+ if (!candidate.candidate || candidate.sdpMLineIndex === undefined) {
+ throw new Error('Invalid candidate: missing candidate or sdpMLineIndex');
+ }
+
+ // Убеждаемся, что candidate имеет правильный формат RTCIceCandidateInit
+ const normalizedCandidate = {
+ candidate: candidate.candidate,
+ sdpMLineIndex: candidate.sdpMLineIndex,
+ sdpMid: candidate.sdpMid || null
+ };
+
+ console.log('Normalized ICE candidate:', normalizedCandidate);
+
+ // Если remote description еще не установлен, буферизуем candidate
+ if (!this.isRemoteDescriptionSet) {
+ this.pendingICECandidates.push(normalizedCandidate);
+ this.log(`ICE candidate буферизован (всего: ${this.pendingICECandidates.length})`, 'info');
+ return;
+ }
+
+ // Иначе добавляем candidate немедленно
+ await this.localConnection.addIceCandidate(normalizedCandidate);
+ this.log('ICE candidate добавлен успешно', 'success');
} catch (error) {
this.log(`Ошибка добавления ICE candidate: ${error.message}`, 'error');
+ console.error('ICE candidate error details:', error, 'Received data:', data);
}
}
@@ -862,35 +2870,6 @@ class GodEyeOperator {
}
}
- startPingMonitoring() {
- if (this.pingInterval) {
- clearInterval(this.pingInterval);
- }
-
- this.pingInterval = setInterval(() => {
- if (this.socket && this.socket.connected) {
- const startTime = Date.now();
- this.socket.emit('ping', startTime);
-
- // Timeout после 5 секунд
- setTimeout(() => {
- if (this.elements.pingIndicator.textContent === 'Ping: ...') {
- this.elements.pingIndicator.textContent = 'Ping: Timeout';
- this.elements.pingIndicator.style.color = '#f44336';
- }
- }, 5000);
- }
- }, 10000); // Каждые 10 секунд
- }
-
- stopPingMonitoring() {
- if (this.pingInterval) {
- clearInterval(this.pingInterval);
- this.pingInterval = null;
- }
- this.elements.pingIndicator.textContent = 'Ping: --';
- this.elements.pingIndicator.style.color = '#ccc';
- }
}
// Initialize application when DOM is loaded
diff --git a/desktop-operator/src/renderer/index.html b/desktop-operator/src/renderer/index.html
index 32ee0e2..dc277ed 100644
--- a/desktop-operator/src/renderer/index.html
+++ b/desktop-operator/src/renderer/index.html
@@ -11,11 +11,101 @@