Files
god_eye/backend/public/mobile.html
2025-10-04 11:55:55 +09:00

815 lines
31 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>