connection fixes
This commit is contained in:
@@ -61,40 +61,6 @@ function initializeSocket() {
|
||||
socket.on('device:disconnected', (data) => {
|
||||
logMessage('warn', `Устройство отключено: ${data.deviceId}`);
|
||||
});
|
||||
|
||||
// Обработчики событий сессий
|
||||
socket.on('session:created', (data) => {
|
||||
logMessage('info', `Сессия создана: ${data.sessionId} для устройства ${data.deviceId}`);
|
||||
updateOperatorSessions();
|
||||
});
|
||||
|
||||
socket.on('session:accepted', (data) => {
|
||||
logMessage('info', `Сессия принята: ${data.sessionId}`);
|
||||
currentSessionId = data.sessionId;
|
||||
document.getElementById('current-session-id').value = currentSessionId;
|
||||
|
||||
// Показываем кнопку веб-камеры для оператора
|
||||
showOperatorWebcamButton();
|
||||
|
||||
updateOperatorSessions();
|
||||
showAlert('Сессия принята! Можно начинать WebRTC соединение.', 'success');
|
||||
});
|
||||
|
||||
socket.on('session:rejected', (data) => {
|
||||
logMessage('warn', `Сессия отклонена: ${data.sessionId} - ${data.error}`);
|
||||
updateOperatorSessions();
|
||||
showAlert(`Сессия отклонена: ${data.error}`, 'warning');
|
||||
});
|
||||
|
||||
socket.on('session:ended', (data) => {
|
||||
logMessage('info', `Сессия завершена: ${data.sessionId}`);
|
||||
updateOperatorSessions();
|
||||
|
||||
// Скрываем элементы веб-камеры
|
||||
hideOperatorWebcamButton();
|
||||
const webcamVideo = document.getElementById('operatorWebcam');
|
||||
if (webcamVideo) webcamVideo.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Обновление статуса подключения
|
||||
@@ -181,6 +147,8 @@ function connectAndroid() {
|
||||
});
|
||||
|
||||
androidSocket.on('camera:request', (data) => {
|
||||
console.log('🎥 ПОЛУЧЕН ЗАПРОС КАМЕРЫ:', data);
|
||||
logMessage('info', `🎥 Получен запрос камеры: ${JSON.stringify(data)}`);
|
||||
showCameraRequest(data);
|
||||
});
|
||||
|
||||
@@ -213,6 +181,7 @@ function disconnectAndroid() {
|
||||
}
|
||||
|
||||
function showCameraRequest(data) {
|
||||
console.log('📱 ПОКАЗЫВАЕМ ЗАПРОС КАМЕРЫ:', data);
|
||||
const container = document.getElementById('android-requests');
|
||||
const requestDiv = document.createElement('div');
|
||||
requestDiv.className = 'session-card session-pending';
|
||||
@@ -227,19 +196,30 @@ function showCameraRequest(data) {
|
||||
|
||||
container.appendChild(requestDiv);
|
||||
logMessage('info', `Получен запрос камеры от ${data.operatorId}`);
|
||||
|
||||
// Автоматически принимаем запрос через 1 секунду для удобства тестирования
|
||||
console.log('⏰ ЗАПЛАНИРОВАНО АВТОПРИНЯТИЕ ЧЕРЕЗ 1 СЕКУНДУ');
|
||||
setTimeout(() => {
|
||||
console.log('⏰ ВЫПОЛНЯЕМ АВТОПРИНЯТИЕ');
|
||||
acceptCameraRequest(data.sessionId);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function acceptCameraRequest(sessionId) {
|
||||
console.log('✅ ПРИНЯТИЕ ЗАПРОСА КАМЕРЫ:', sessionId);
|
||||
if (androidSocket) {
|
||||
androidSocket.emit('camera:response', {
|
||||
sessionId,
|
||||
accepted: true,
|
||||
streamUrl: 'webrtc'
|
||||
});
|
||||
console.log('✅ ОТПРАВЛЕНО camera:response с accepted=true');
|
||||
|
||||
// Перемещаем в активные сессии
|
||||
moveToActiveSessions(sessionId, 'active');
|
||||
logMessage('info', `Запрос камеры принят: ${sessionId}`);
|
||||
} else {
|
||||
console.error('❌ androidSocket НЕ ПОДКЛЮЧЕН!');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,14 +314,6 @@ function connectOperator() {
|
||||
document.getElementById('operator-disconnect').disabled = false;
|
||||
|
||||
showAvailableDevices(data.availableDevices || []);
|
||||
updateOperatorSessions(); // Обновляем список сессий после подключения
|
||||
|
||||
// Автоматическое обновление сессий каждые 3 секунды
|
||||
setInterval(() => {
|
||||
if (operatorSocket && operatorSocket.connected) {
|
||||
updateOperatorSessions();
|
||||
}
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
operatorSocket.on('device:connected', (data) => {
|
||||
@@ -352,12 +324,6 @@ function connectOperator() {
|
||||
operatorSocket.on('camera:stream-ready', (data) => {
|
||||
logMessage('info', `Камера готова: ${data.sessionId}`);
|
||||
showOperatorSession(data.sessionId, 'active');
|
||||
updateOperatorSessions();
|
||||
});
|
||||
|
||||
operatorSocket.on('camera:request-sent', (data) => {
|
||||
logMessage('info', `Запрос отправлен: ${data.sessionId}`);
|
||||
updateOperatorSessions();
|
||||
});
|
||||
|
||||
operatorSocket.on('camera:denied', (data) => {
|
||||
@@ -545,7 +511,26 @@ async function endOperatorSession(sessionId) {
|
||||
}
|
||||
|
||||
async function refreshSessions() {
|
||||
updateOperatorSessions();
|
||||
const operatorId = document.getElementById('operator-id').value;
|
||||
if (!operatorId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/operators/sessions', {
|
||||
headers: { 'X-Operator-Id': operatorId }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const container = document.getElementById('operator-sessions');
|
||||
container.innerHTML = '';
|
||||
|
||||
data.sessions.forEach(session => {
|
||||
showOperatorSession(session.sessionId, session.status);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logMessage('error', 'Ошибка загрузки сессий: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// === ADMIN FUNCTIONS ===
|
||||
@@ -679,9 +664,11 @@ function setupAndroidWebRTC() {
|
||||
|
||||
androidSocket.on('webrtc:ice-candidate', async (data) => {
|
||||
try {
|
||||
logMessage('info', `📡 Получен ICE candidate: ${JSON.stringify(data.candidate)}`);
|
||||
const pc = getOrCreatePeerConnection();
|
||||
const candidate = JSON.parse(data.candidate);
|
||||
const candidate = data.candidate;
|
||||
await pc.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
logMessage('success', '✅ ICE candidate добавлен успешно');
|
||||
} catch (error) {
|
||||
logMessage('error', 'ICE candidate ошибка: ' + error.message);
|
||||
}
|
||||
@@ -706,9 +693,11 @@ function setupOperatorWebRTC() {
|
||||
|
||||
operatorSocket.on('webrtc:ice-candidate', async (data) => {
|
||||
try {
|
||||
logMessage('info', `📡 Получен ICE candidate: ${JSON.stringify(data.candidate)}`);
|
||||
const pc = getOrCreatePeerConnection();
|
||||
const candidate = JSON.parse(data.candidate);
|
||||
const candidate = data.candidate;
|
||||
await pc.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
logMessage('success', '✅ ICE candidate добавлен успешно');
|
||||
} catch (error) {
|
||||
logMessage('error', 'ICE candidate ошибка: ' + error.message);
|
||||
}
|
||||
@@ -721,11 +710,13 @@ function getOrCreatePeerConnection() {
|
||||
|
||||
peerConnection.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
const candidateData = JSON.stringify({
|
||||
const candidateData = {
|
||||
candidate: event.candidate.candidate,
|
||||
sdpMid: event.candidate.sdpMid,
|
||||
sdpMLineIndex: event.candidate.sdpMLineIndex
|
||||
});
|
||||
};
|
||||
|
||||
logMessage('info', `📤 Отправляем ICE candidate: ${JSON.stringify(candidateData)}`);
|
||||
|
||||
if (androidSocket) {
|
||||
androidSocket.emit('webrtc:ice-candidate', {
|
||||
@@ -809,95 +800,9 @@ function stopLocalVideo() {
|
||||
}
|
||||
}
|
||||
|
||||
// Обновление списка сессий оператора
|
||||
function updateOperatorSessions() {
|
||||
console.log('updateOperatorSessions called, operatorSocket:', operatorSocket);
|
||||
|
||||
if (!operatorSocket) {
|
||||
console.log('No operatorSocket, returning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!operatorSocket.connected) {
|
||||
console.log('operatorSocket not connected, returning');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Requesting sessions list...');
|
||||
operatorSocket.emit('sessions:list', {}, (sessions) => {
|
||||
console.log('Received sessions:', sessions);
|
||||
|
||||
const container = document.getElementById('operator-sessions');
|
||||
if (!container) {
|
||||
console.log('No operator-sessions container found');
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
if (!sessions || sessions.length === 0) {
|
||||
container.innerHTML = '<p>Нет активных сессий</p>';
|
||||
console.log('No sessions to display');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Displaying', sessions.length, 'sessions');
|
||||
sessions.forEach(session => {
|
||||
const sessionCard = document.createElement('div');
|
||||
sessionCard.className = `session-card session-${session.status}`;
|
||||
|
||||
const statusText = {
|
||||
'pending': 'Ожидание',
|
||||
'active': 'Активна',
|
||||
'rejected': 'Отклонена',
|
||||
'ended': 'Завершена'
|
||||
};
|
||||
|
||||
sessionCard.innerHTML = `
|
||||
<h4>Сессия: ${session.sessionId ? session.sessionId.substr(0, 8) : session.id ? session.id.substr(0, 8) : 'N/A'}...</h4>
|
||||
<p><strong>Устройство:</strong> ${session.deviceId}</p>
|
||||
<p><strong>Камера:</strong> ${session.cameraType}</p>
|
||||
<p><strong>Статус:</strong> ${statusText[session.status] || session.status}</p>
|
||||
<p><strong>Создана:</strong> ${new Date(session.createdAt).toLocaleString()}</p>
|
||||
${session.status === 'active' ? `
|
||||
<button class="btn" onclick="switchCamera('${session.sessionId || session.id}', 'front')">Фронтальная</button>
|
||||
<button class="btn" onclick="switchCamera('${session.sessionId || session.id}', 'back')">Основная</button>
|
||||
<button class="btn btn-danger" onclick="endSession('${session.sessionId || session.id}')">Завершить</button>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
container.appendChild(sessionCard);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Завершение сессии
|
||||
function endSession(sessionId) {
|
||||
if (!operatorSocket) {
|
||||
showAlert('Не подключен как оператор', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
operatorSocket.emit('session:end', { sessionId });
|
||||
logMessage('info', `Завершаем сессию: ${sessionId}`);
|
||||
}
|
||||
|
||||
// Переключение камеры
|
||||
function switchCamera(sessionId, cameraType) {
|
||||
if (!operatorSocket) {
|
||||
showAlert('Не подключен как оператор', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
operatorSocket.emit('camera:switch', {
|
||||
sessionId,
|
||||
cameraType
|
||||
});
|
||||
logMessage('info', `Переключаем камеру: ${sessionId} на ${cameraType}`);
|
||||
}
|
||||
|
||||
// Показ уведомления
|
||||
function showAlert(message, type) {
|
||||
// Вспомогательная функция для показа уведомлений
|
||||
function showAlert(type, message) {
|
||||
// Создаем временное уведомление
|
||||
const alert = document.createElement('div');
|
||||
alert.className = `alert alert-${type}`;
|
||||
alert.textContent = message;
|
||||
@@ -909,66 +814,10 @@ function showAlert(message, type) {
|
||||
|
||||
document.body.appendChild(alert);
|
||||
|
||||
// Удаляем через 5 секунд
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(alert)) {
|
||||
document.body.removeChild(alert);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// === ОПЕРАТОР: Веб-камера и WebRTC ===
|
||||
function showOperatorWebcamButton() {
|
||||
const btn = document.getElementById('startOperatorWebcam');
|
||||
if (btn) btn.style.display = '';
|
||||
}
|
||||
function hideOperatorWebcamButton() {
|
||||
const btn = document.getElementById('startOperatorWebcam');
|
||||
if (btn) btn.style.display = 'none';
|
||||
}
|
||||
function startOperatorWebcam() {
|
||||
navigator.mediaDevices.getUserMedia({ video: true, audio: false })
|
||||
.then(stream => {
|
||||
operatorWebcamStream = stream;
|
||||
const video = document.getElementById('operatorWebcam');
|
||||
if (video) {
|
||||
video.srcObject = stream;
|
||||
video.style.display = '';
|
||||
}
|
||||
startOperatorWebRTC(stream);
|
||||
})
|
||||
.catch(err => {
|
||||
showAlert('danger', 'Ошибка доступа к веб-камере: ' + err);
|
||||
});
|
||||
}
|
||||
function startOperatorWebRTC(stream) {
|
||||
operatorWebcamPeer = new RTCPeerConnection(rtcConfig);
|
||||
stream.getTracks().forEach(track => operatorWebcamPeer.addTrack(track, stream));
|
||||
operatorWebcamPeer.onicecandidate = e => {
|
||||
if (e.candidate) {
|
||||
socket.emit('webrtc:ice-candidate', { candidate: e.candidate });
|
||||
}
|
||||
};
|
||||
operatorWebcamPeer.createOffer().then(offer => {
|
||||
return operatorWebcamPeer.setLocalDescription(offer);
|
||||
}).then(() => {
|
||||
socket.emit('webrtc:offer', { offer: operatorWebcamPeer.localDescription });
|
||||
});
|
||||
}
|
||||
socket.on('webrtc:answer', data => {
|
||||
if (operatorWebcamPeer) {
|
||||
operatorWebcamPeer.setRemoteDescription(new RTCSessionDescription(data.answer));
|
||||
}
|
||||
});
|
||||
socket.on('webrtc:ice-candidate', data => {
|
||||
if (operatorWebcamPeer && data.candidate) {
|
||||
operatorWebcamPeer.addIceCandidate(new RTCIceCandidate(data.candidate));
|
||||
}
|
||||
});
|
||||
// Показывать кнопку веб-камеры при принятии сессии
|
||||
socket.on('camera:response', data => {
|
||||
if (data.accepted) {
|
||||
showOperatorWebcamButton();
|
||||
} else {
|
||||
hideOperatorWebcamButton();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -400,14 +400,13 @@
|
||||
<label>Operator ID:</label>
|
||||
<input type="text" id="operator-id" value="demo-operator-001" placeholder="Введите ID оператора">
|
||||
</div>
|
||||
|
||||
<button class="btn btn-success" id="operator-connect" onclick="connectOperator()">
|
||||
Подключиться как оператор
|
||||
</button>
|
||||
<button class="btn btn-danger" id="operator-disconnect" onclick="disconnectOperator()" disabled>
|
||||
Отключиться
|
||||
</button>
|
||||
<button class="btn" id="startOperatorWebcam" style="display:none">Включить веб-камеру оператора</button>
|
||||
<video id="operatorWebcam" autoplay playsinline style="width:320px;display:none"></video>
|
||||
|
||||
<div class="grid">
|
||||
<div>
|
||||
|
||||
@@ -1,815 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>GodEye Mobile Camera</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.mobile-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
padding: 10px 15px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 18px;
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.status.connected {
|
||||
background: #4CAF50;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.status.disconnected {
|
||||
background: #f44336;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#localVideo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transform: scaleX(-1); /* Зеркало для фронтальной камеры */
|
||||
}
|
||||
|
||||
.no-camera {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
padding: 15px 20px;
|
||||
border-radius: 25px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-camera {
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-camera:hover {
|
||||
background: #45a049;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.btn-camera:disabled {
|
||||
background: #666;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-switch {
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-switch:hover {
|
||||
background: #1976D2;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.btn-disconnect {
|
||||
background: #f44336;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-disconnect:hover {
|
||||
background: #d32f2f;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.device-info {
|
||||
position: absolute;
|
||||
top: 80px;
|
||||
left: 15px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.requests-panel {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
display: none;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.requests-panel.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.request-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.btn-accept {
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-reject {
|
||||
background: #f44336;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.logs {
|
||||
position: absolute;
|
||||
bottom: 120px;
|
||||
left: 15px;
|
||||
right: 15px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
backdrop-filter: blur(10px);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.logs.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
margin-bottom: 2px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.log-entry.info { color: #2196F3; }
|
||||
.log-entry.success { color: #4CAF50; }
|
||||
.log-entry.warning { color: #FF9800; }
|
||||
.log-entry.error { color: #f44336; }
|
||||
|
||||
@media (orientation: landscape) {
|
||||
.controls {
|
||||
right: 20px;
|
||||
left: auto;
|
||||
transform: none;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.device-info {
|
||||
top: 15px;
|
||||
left: 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="mobile-container">
|
||||
<div class="header">
|
||||
<h1>📱 GodEye Mobile</h1>
|
||||
<div id="status" class="status disconnected">Отключено</div>
|
||||
</div>
|
||||
|
||||
<div class="video-container">
|
||||
<video id="localVideo" autoplay playsinline muted></video>
|
||||
<div id="no-camera" class="no-camera">
|
||||
<h3>📷 Камера не активна</h3>
|
||||
<p>Нажмите кнопку включения камеры</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="device-info" id="device-info">
|
||||
<div>📱 <strong>Device ID:</strong> <span id="device-id">Генерация...</span></div>
|
||||
<div>📷 <strong>Camera:</strong> <span id="current-camera">none</span></div>
|
||||
<div>🔗 <strong>Sessions:</strong> <span id="session-count">0</span></div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button id="btn-camera" class="btn btn-camera" title="Включить/выключить камеру">
|
||||
📷
|
||||
</button>
|
||||
<button id="btn-switch" class="btn btn-switch" title="Переключить камеру">
|
||||
🔄
|
||||
</button>
|
||||
<button id="btn-disconnect" class="btn btn-disconnect" title="Отключиться">
|
||||
❌
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="requests-panel" class="requests-panel">
|
||||
<h3>📞 Запрос на доступ к камере</h3>
|
||||
<p>Оператор <strong id="operator-id"></strong> запрашивает доступ к камере</p>
|
||||
<div class="request-buttons">
|
||||
<button id="btn-accept" class="btn-accept">✅ Принять</button>
|
||||
<button id="btn-reject" class="btn-reject">❌ Отклонить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="logs" class="logs">
|
||||
<!-- Логи здесь -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script>
|
||||
class MobileCameraApp {
|
||||
constructor() {
|
||||
this.socket = null;
|
||||
this.deviceId = this.generateDeviceId();
|
||||
this.localStream = null;
|
||||
this.currentCamera = 'back';
|
||||
this.activeSessions = new Map();
|
||||
this.pendingRequests = new Map();
|
||||
this.peerConnections = new Map(); // WebRTC connections
|
||||
this.logTimeout = null;
|
||||
this.isConnected = false;
|
||||
this.isCameraOn = false;
|
||||
|
||||
this.elements = {
|
||||
status: document.getElementById('status'),
|
||||
localVideo: document.getElementById('localVideo'),
|
||||
noCamera: document.getElementById('no-camera'),
|
||||
deviceId: document.getElementById('device-id'),
|
||||
currentCamera: document.getElementById('current-camera'),
|
||||
sessionCount: document.getElementById('session-count'),
|
||||
requestsPanel: document.getElementById('requests-panel'),
|
||||
operatorId: document.getElementById('operator-id'),
|
||||
logs: document.getElementById('logs'),
|
||||
btnCamera: document.getElementById('btn-camera'),
|
||||
btnSwitch: document.getElementById('btn-switch'),
|
||||
btnDisconnect: document.getElementById('btn-disconnect'),
|
||||
btnAccept: document.getElementById('btn-accept'),
|
||||
btnReject: document.getElementById('btn-reject')
|
||||
};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
generateDeviceId() {
|
||||
// Используем комбинацию для мобильного устройства
|
||||
const timestamp = Date.now().toString(36);
|
||||
const random = Math.random().toString(36).substr(2, 9);
|
||||
return `mobile_${timestamp}_${random}`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.elements.deviceId.textContent = this.deviceId;
|
||||
this.setupEventListeners();
|
||||
this.connectToServer();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
this.elements.btnCamera.addEventListener('click', () => this.toggleCamera());
|
||||
this.elements.btnSwitch.addEventListener('click', () => this.switchCamera());
|
||||
this.elements.btnDisconnect.addEventListener('click', () => this.disconnect());
|
||||
this.elements.btnAccept.addEventListener('click', () => this.acceptRequest());
|
||||
this.elements.btnReject.addEventListener('click', () => this.rejectRequest());
|
||||
|
||||
// Обработка поворота экрана
|
||||
window.addEventListener('orientationchange', () => {
|
||||
setTimeout(() => this.adjustLayout(), 100);
|
||||
});
|
||||
}
|
||||
|
||||
connectToServer() {
|
||||
this.log('Подключение к серверу...', 'info');
|
||||
this.socket = io();
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
this.log('✅ Подключено к серверу', 'success');
|
||||
this.isConnected = true;
|
||||
this.updateStatus('connected');
|
||||
this.registerDevice();
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', () => {
|
||||
this.log('❌ Отключено от сервера', 'warning');
|
||||
this.isConnected = false;
|
||||
this.updateStatus('disconnected');
|
||||
this.stopCamera();
|
||||
});
|
||||
|
||||
this.socket.on('camera:request', (data) => {
|
||||
this.handleCameraRequest(data);
|
||||
});
|
||||
|
||||
this.socket.on('camera:switch', (data) => {
|
||||
this.handleCameraSwitchRequest(data);
|
||||
});
|
||||
|
||||
this.socket.on('camera:disconnect', (data) => {
|
||||
this.handleDisconnectRequest(data);
|
||||
});
|
||||
|
||||
// WebRTC обработчики
|
||||
this.socket.on('webrtc:offer', (data) => {
|
||||
this.handleWebRTCOffer(data);
|
||||
});
|
||||
|
||||
this.socket.on('webrtc:answer', (data) => {
|
||||
this.handleWebRTCAnswer(data);
|
||||
});
|
||||
|
||||
this.socket.on('webrtc:ice-candidate', (data) => {
|
||||
this.handleWebRTCIceCandidate(data);
|
||||
});
|
||||
|
||||
this.socket.on('register:success', (data) => {
|
||||
this.log(`📱 Устройство зарегистрировано: ${data.deviceId}`, 'success');
|
||||
});
|
||||
|
||||
this.socket.on('register:error', (error) => {
|
||||
this.log(`❌ Ошибка регистрации: ${error.message}`, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
registerDevice() {
|
||||
const deviceInfo = {
|
||||
platform: 'mobile_web',
|
||||
userAgent: navigator.userAgent,
|
||||
cameras: ['back', 'front'],
|
||||
capabilities: {
|
||||
video: true,
|
||||
audio: false
|
||||
}
|
||||
};
|
||||
|
||||
this.socket.emit('register:mobile_web', {
|
||||
deviceId: this.deviceId,
|
||||
deviceInfo: deviceInfo
|
||||
});
|
||||
}
|
||||
|
||||
async toggleCamera() {
|
||||
if (this.isCameraOn) {
|
||||
this.stopCamera();
|
||||
} else {
|
||||
await this.startCamera();
|
||||
}
|
||||
}
|
||||
|
||||
async startCamera() {
|
||||
try {
|
||||
this.log(`📷 Запуск камеры (${this.currentCamera})...`, 'info');
|
||||
|
||||
const constraints = {
|
||||
video: {
|
||||
facingMode: this.currentCamera === 'front' ? 'user' : 'environment',
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 }
|
||||
},
|
||||
audio: false
|
||||
};
|
||||
|
||||
this.localStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
this.elements.localVideo.srcObject = this.localStream;
|
||||
|
||||
this.isCameraOn = true;
|
||||
this.elements.btnCamera.textContent = '📷';
|
||||
this.elements.btnCamera.style.background = '#f44336';
|
||||
this.elements.noCamera.style.display = 'none';
|
||||
this.elements.localVideo.style.display = 'block';
|
||||
|
||||
// Для фронтальной камеры применяем зеркальное отображение
|
||||
this.elements.localVideo.style.transform =
|
||||
this.currentCamera === 'front' ? 'scaleX(-1)' : 'scaleX(1)';
|
||||
|
||||
this.updateCameraInfo();
|
||||
this.log(`✅ Камера ${this.currentCamera} запущена`, 'success');
|
||||
|
||||
} catch (error) {
|
||||
this.log(`❌ Ошибка доступа к камере: ${error.message}`, 'error');
|
||||
this.isCameraOn = false;
|
||||
}
|
||||
}
|
||||
|
||||
stopCamera() {
|
||||
if (this.localStream) {
|
||||
this.localStream.getTracks().forEach(track => track.stop());
|
||||
this.localStream = null;
|
||||
}
|
||||
|
||||
this.isCameraOn = false;
|
||||
this.elements.btnCamera.textContent = '📷';
|
||||
this.elements.btnCamera.style.background = '#4CAF50';
|
||||
this.elements.localVideo.style.display = 'none';
|
||||
this.elements.noCamera.style.display = 'block';
|
||||
|
||||
this.updateCameraInfo();
|
||||
this.log('📷 Камера остановлена', 'info');
|
||||
}
|
||||
|
||||
async switchCamera(targetCamera = null) {
|
||||
if (!this.isCameraOn) {
|
||||
this.log('⚠️ Сначала включите камеру', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Если указан конкретный тип камеры - используем его
|
||||
if (targetCamera) {
|
||||
this.currentCamera = targetCamera;
|
||||
} else {
|
||||
// Иначе переключаем на противоположную
|
||||
this.currentCamera = this.currentCamera === 'front' ? 'back' : 'front';
|
||||
}
|
||||
|
||||
this.log(`🔄 Переключение камеры на: ${this.currentCamera}`, 'info');
|
||||
this.stopCamera();
|
||||
await this.startCamera();
|
||||
|
||||
// Обновляем все активные WebRTC соединения
|
||||
await this.updateWebRTCStreams();
|
||||
}
|
||||
|
||||
handleCameraRequest(data) {
|
||||
const { sessionId, operatorId, cameraType } = data;
|
||||
this.log(`📞 Запрос камеры от оператора ${operatorId}`, 'info');
|
||||
|
||||
this.pendingRequests.set(sessionId, { operatorId, cameraType, sessionId });
|
||||
this.showRequestPanel(operatorId, sessionId);
|
||||
}
|
||||
|
||||
handleCameraSwitchRequest(data) {
|
||||
const { sessionId, cameraType } = data;
|
||||
this.log(`🔄 Запрос переключения камеры: ${cameraType}`, 'info');
|
||||
|
||||
if (this.activeSessions.has(sessionId)) {
|
||||
if (this.isCameraOn) {
|
||||
// Переключаем на конкретно запрошенный тип камеры
|
||||
this.switchCamera(cameraType);
|
||||
} else {
|
||||
// Если камера не включена, просто обновляем тип
|
||||
this.currentCamera = cameraType;
|
||||
this.log(`📝 Тип камеры обновлен на: ${cameraType}`, 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleDisconnectRequest(data) {
|
||||
const { sessionId } = data;
|
||||
this.log(`🔌 Запрос отключения сессии: ${sessionId}`, 'info');
|
||||
|
||||
if (this.activeSessions.has(sessionId)) {
|
||||
this.activeSessions.delete(sessionId);
|
||||
this.updateSessionCount();
|
||||
}
|
||||
}
|
||||
|
||||
showRequestPanel(operatorId, sessionId) {
|
||||
this.elements.operatorId.textContent = operatorId;
|
||||
this.elements.requestsPanel.classList.add('visible');
|
||||
this.currentRequestSessionId = sessionId;
|
||||
}
|
||||
|
||||
hideRequestPanel() {
|
||||
this.elements.requestsPanel.classList.remove('visible');
|
||||
this.currentRequestSessionId = null;
|
||||
}
|
||||
|
||||
async acceptRequest() {
|
||||
const sessionId = this.currentRequestSessionId;
|
||||
const requestData = this.pendingRequests.get(sessionId);
|
||||
|
||||
if (!requestData) return;
|
||||
|
||||
this.log(`✅ Принят запрос сессии: ${sessionId}`, 'success');
|
||||
|
||||
// Запускаем камеру если не запущена
|
||||
if (!this.isCameraOn) {
|
||||
this.currentCamera = requestData.cameraType || 'back';
|
||||
await this.startCamera();
|
||||
}
|
||||
|
||||
// Добавляем сессию
|
||||
this.activeSessions.set(sessionId, {
|
||||
operatorId: requestData.operatorId,
|
||||
cameraType: this.currentCamera,
|
||||
startTime: new Date()
|
||||
});
|
||||
|
||||
// Отправляем подтверждение с поддержкой WebRTC
|
||||
this.socket.emit('camera:response', {
|
||||
sessionId: sessionId,
|
||||
accepted: true,
|
||||
streamUrl: 'webrtc', // Указываем что используем WebRTC
|
||||
cameraType: this.currentCamera
|
||||
});
|
||||
|
||||
this.pendingRequests.delete(sessionId);
|
||||
this.hideRequestPanel();
|
||||
this.updateSessionCount();
|
||||
}
|
||||
|
||||
rejectRequest() {
|
||||
const sessionId = this.currentRequestSessionId;
|
||||
const requestData = this.pendingRequests.get(sessionId);
|
||||
|
||||
if (!requestData) return;
|
||||
|
||||
this.log(`❌ Отклонен запрос сессии: ${sessionId}`, 'warning');
|
||||
|
||||
this.socket.emit('camera:response', {
|
||||
sessionId: sessionId,
|
||||
accepted: false,
|
||||
error: 'Пользователь отклонил запрос'
|
||||
});
|
||||
|
||||
this.pendingRequests.delete(sessionId);
|
||||
this.hideRequestPanel();
|
||||
}
|
||||
|
||||
// ===== WebRTC Methods =====
|
||||
async createPeerConnection(sessionId) {
|
||||
const config = {
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' }
|
||||
]
|
||||
};
|
||||
|
||||
const peerConnection = new RTCPeerConnection(config);
|
||||
|
||||
// Добавляем обработчики событий
|
||||
peerConnection.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
this.socket.emit('webrtc:ice-candidate', {
|
||||
sessionId: sessionId,
|
||||
candidate: event.candidate
|
||||
});
|
||||
this.log('🧊 ICE candidate отправлен', 'info');
|
||||
}
|
||||
};
|
||||
|
||||
peerConnection.onconnectionstatechange = () => {
|
||||
this.log(`🔗 WebRTC состояние: ${peerConnection.connectionState}`, 'info');
|
||||
if (peerConnection.connectionState === 'connected') {
|
||||
this.log('✅ WebRTC соединение установлено', 'success');
|
||||
} else if (peerConnection.connectionState === 'failed') {
|
||||
this.log('❌ WebRTC соединение неудачно', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
peerConnection.onicegatheringstatechange = () => {
|
||||
this.log(`❄️ ICE gathering: ${peerConnection.iceGatheringState}`, 'info');
|
||||
};
|
||||
|
||||
// Добавляем локальный поток если есть
|
||||
if (this.localStream) {
|
||||
this.localStream.getTracks().forEach(track => {
|
||||
peerConnection.addTrack(track, this.localStream);
|
||||
this.log(`🎥 Трек добавлен: ${track.kind}`, 'info');
|
||||
});
|
||||
}
|
||||
|
||||
return peerConnection;
|
||||
}
|
||||
|
||||
async handleWebRTCOffer(data) {
|
||||
const { sessionId, offer } = data;
|
||||
this.log(`📞 Получен WebRTC offer для сессии: ${sessionId}`, 'info');
|
||||
|
||||
try {
|
||||
// Создаем новое peer connection для этой сессии
|
||||
const peerConnection = await this.createPeerConnection(sessionId);
|
||||
this.peerConnections.set(sessionId, peerConnection);
|
||||
|
||||
// Устанавливаем remote description
|
||||
await peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
|
||||
this.log('📝 Remote description установлен', 'info');
|
||||
|
||||
// Создаем answer
|
||||
const answer = await peerConnection.createAnswer();
|
||||
await peerConnection.setLocalDescription(answer);
|
||||
|
||||
// Отправляем answer
|
||||
this.socket.emit('webrtc:answer', {
|
||||
sessionId: sessionId,
|
||||
answer: answer
|
||||
});
|
||||
|
||||
this.log('✅ WebRTC answer отправлен', 'success');
|
||||
|
||||
} catch (error) {
|
||||
this.log(`❌ Ошибка WebRTC offer: ${error.message}`, 'error');
|
||||
this.socket.emit('webrtc:error', {
|
||||
sessionId: sessionId,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async handleWebRTCAnswer(data) {
|
||||
const { sessionId, answer } = data;
|
||||
this.log(`📞 Получен WebRTC answer для сессии: ${sessionId}`, 'info');
|
||||
|
||||
try {
|
||||
const peerConnection = this.peerConnections.get(sessionId);
|
||||
if (peerConnection) {
|
||||
await peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
|
||||
this.log('✅ WebRTC answer обработан', 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(`❌ Ошибка WebRTC answer: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async handleWebRTCIceCandidate(data) {
|
||||
const { sessionId, candidate } = data;
|
||||
|
||||
try {
|
||||
const peerConnection = this.peerConnections.get(sessionId);
|
||||
if (peerConnection) {
|
||||
await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
this.log('🧊 ICE candidate добавлен', 'info');
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(`❌ Ошибка ICE candidate: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async updateWebRTCStreams() {
|
||||
if (!this.localStream) {
|
||||
this.log('⚠️ Нет локального потока для обновления', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
this.log('🔄 Обновление WebRTC потоков...', 'info');
|
||||
|
||||
// Обновляем все активные peer connections
|
||||
for (const [sessionId, peerConnection] of this.peerConnections.entries()) {
|
||||
try {
|
||||
// Удаляем старые треки
|
||||
const senders = peerConnection.getSenders();
|
||||
for (const sender of senders) {
|
||||
if (sender.track) {
|
||||
peerConnection.removeTrack(sender);
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляем новые треки
|
||||
this.localStream.getTracks().forEach(track => {
|
||||
peerConnection.addTrack(track, this.localStream);
|
||||
});
|
||||
|
||||
this.log(`✅ WebRTC поток обновлен для сессии: ${sessionId}`, 'success');
|
||||
|
||||
} catch (error) {
|
||||
this.log(`❌ Ошибка обновления WebRTC потока: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.log('🔌 Отключение от сервера...', 'info');
|
||||
this.stopCamera();
|
||||
|
||||
// Закрываем все WebRTC соединения
|
||||
this.peerConnections.forEach((pc, sessionId) => {
|
||||
pc.close();
|
||||
this.log(`🔒 WebRTC соединение закрыто: ${sessionId}`, 'info');
|
||||
});
|
||||
this.peerConnections.clear();
|
||||
|
||||
if (this.socket) {
|
||||
this.socket.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
updateStatus(status) {
|
||||
this.elements.status.textContent = status === 'connected' ? 'Подключено' : 'Отключено';
|
||||
this.elements.status.className = `status ${status}`;
|
||||
}
|
||||
|
||||
updateCameraInfo() {
|
||||
this.elements.currentCamera.textContent =
|
||||
this.isCameraOn ? this.currentCamera : 'none';
|
||||
}
|
||||
|
||||
updateSessionCount() {
|
||||
this.elements.sessionCount.textContent = this.activeSessions.size;
|
||||
}
|
||||
|
||||
adjustLayout() {
|
||||
// Адаптация под поворот экрана
|
||||
setTimeout(() => {
|
||||
if (this.localStream && this.elements.localVideo.srcObject) {
|
||||
this.elements.localVideo.play();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
log(message, type = 'info') {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const logEntry = document.createElement('div');
|
||||
logEntry.className = `log-entry ${type}`;
|
||||
logEntry.textContent = `[${timestamp}] ${message}`;
|
||||
|
||||
this.elements.logs.appendChild(logEntry);
|
||||
this.elements.logs.scrollTop = this.elements.logs.scrollHeight;
|
||||
|
||||
// Показываем логи на 3 секунды
|
||||
this.elements.logs.classList.add('visible');
|
||||
clearTimeout(this.logTimeout);
|
||||
this.logTimeout = setTimeout(() => {
|
||||
this.elements.logs.classList.remove('visible');
|
||||
}, 3000);
|
||||
|
||||
console.log(`[Mobile] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Запуск приложения
|
||||
let app;
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
app = new MobileCameraApp();
|
||||
});
|
||||
|
||||
// Предотвращение случайного обновления страницы
|
||||
window.addEventListener('beforeunload', (e) => {
|
||||
if (app && app.isConnected) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
return '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user