| import os |
| import uuid |
| import json |
| from datetime import datetime |
| from flask import Flask, request, jsonify, send_file |
| from flask_socketio import SocketIO, emit |
| import base64 |
| import io |
|
|
| app = Flask(__name__) |
| app.config['SECRET_KEY'] = 'messenger-secret-key' |
| app.config['DEBUG'] = False |
|
|
| |
| socketio = SocketIO( |
| app, |
| cors_allowed_origins="*", |
| ping_timeout=60, |
| ping_interval=25, |
| logger=True, |
| engineio_logger=False, |
| async_mode='eventlet', |
| max_http_buffer_size=100 * 1024 * 1024 |
| ) |
|
|
| |
| users = {} |
| messages = [] |
| voice_messages = {} |
| active_calls = {} |
|
|
| HTML_TEMPLATE = ''' |
| <!DOCTYPE html> |
| <html> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> |
| <title>Мобильный Мессенджер</title> |
| <style> |
| * { |
| margin: 0; |
| padding: 0; |
| box-sizing: border-box; |
| -webkit-tap-highlight-color: transparent; |
| } |
| |
| body { |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
| background: #f0f2f5; |
| height: 100vh; |
| overflow: hidden; |
| } |
| |
| .app-container { |
| height: 100vh; |
| display: flex; |
| flex-direction: column; |
| max-width: 100%; |
| margin: 0 auto; |
| background: white; |
| } |
| |
| /* Login Screen */ |
| .login-screen { |
| display: flex; |
| flex-direction: column; |
| justify-content: center; |
| align-items: center; |
| height: 100%; |
| padding: 20px; |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
| color: white; |
| } |
| |
| .login-form { |
| background: rgba(255,255,255,0.1); |
| padding: 30px; |
| border-radius: 20px; |
| backdrop-filter: blur(10px); |
| width: 100%; |
| max-width: 320px; |
| } |
| |
| .login-input { |
| width: 100%; |
| padding: 15px; |
| margin: 10px 0; |
| border: none; |
| border-radius: 25px; |
| font-size: 16px; |
| background: rgba(255,255,255,0.9); |
| text-align: center; |
| } |
| |
| .login-btn { |
| width: 100%; |
| padding: 15px; |
| margin: 10px 0; |
| border: none; |
| border-radius: 25px; |
| background: #4CAF50; |
| color: white; |
| font-size: 16px; |
| font-weight: bold; |
| cursor: pointer; |
| } |
| |
| /* Chat Screen */ |
| .chat-screen { |
| display: none; |
| flex-direction: column; |
| height: 100%; |
| } |
| |
| .header { |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
| color: white; |
| padding: 15px; |
| position: relative; |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| } |
| |
| .header-info { |
| flex: 1; |
| } |
| |
| .header h2 { |
| margin-bottom: 5px; |
| font-size: 18px; |
| } |
| |
| .online-info { |
| font-size: 14px; |
| opacity: 0.9; |
| } |
| |
| .call-buttons { |
| display: flex; |
| gap: 10px; |
| } |
| |
| .header-btn { |
| background: rgba(255,255,255,0.2); |
| border: none; |
| border-radius: 50%; |
| width: 44px; |
| height: 44px; |
| color: white; |
| font-size: 18px; |
| cursor: pointer; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| } |
| |
| .messages-container { |
| flex: 1; |
| overflow-y: auto; |
| padding: 10px; |
| background: #f8f9fa; |
| -webkit-overflow-scrolling: touch; |
| } |
| |
| .message { |
| margin: 10px 0; |
| padding: 12px 15px; |
| border-radius: 18px; |
| max-width: 85%; |
| word-wrap: break-word; |
| position: relative; |
| animation: fadeIn 0.3s ease-in; |
| } |
| |
| @keyframes fadeIn { |
| from { opacity: 0; transform: translateY(10px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| |
| .message.own { |
| background: #007AFF; |
| color: white; |
| margin-left: auto; |
| border-bottom-right-radius: 5px; |
| } |
| |
| .message.other { |
| background: white; |
| color: #333; |
| margin-right: auto; |
| border-bottom-left-radius: 5px; |
| box-shadow: 0 1px 2px rgba(0,0,0,0.1); |
| } |
| |
| .message-sender { |
| font-weight: bold; |
| font-size: 12px; |
| margin-bottom: 4px; |
| opacity: 0.8; |
| } |
| |
| .message-time { |
| font-size: 11px; |
| opacity: 0.7; |
| margin-top: 5px; |
| text-align: right; |
| } |
| |
| .voice-message { |
| display: flex; |
| align-items: center; |
| padding: 8px 0; |
| } |
| |
| .voice-play-btn { |
| background: #007AFF; |
| border: none; |
| border-radius: 50%; |
| width: 40px; |
| height: 40px; |
| margin-right: 10px; |
| cursor: pointer; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| color: white; |
| } |
| |
| .voice-duration { |
| font-size: 14px; |
| color: #666; |
| } |
| |
| .input-area { |
| display: flex; |
| padding: 10px; |
| background: white; |
| border-top: 1px solid #e0e0e0; |
| align-items: center; |
| } |
| |
| .message-input { |
| flex: 1; |
| padding: 12px 15px; |
| border: 1px solid #ddd; |
| border-radius: 25px; |
| margin: 0 5px; |
| font-size: 16px; |
| outline: none; |
| background: #f8f9fa; |
| } |
| |
| .action-btn { |
| background: none; |
| border: none; |
| font-size: 24px; |
| padding: 10px; |
| cursor: pointer; |
| border-radius: 50%; |
| transition: background 0.2s; |
| min-width: 44px; |
| min-height: 44px; |
| color: #007AFF; |
| } |
| |
| .action-btn:active { |
| background: rgba(0,0,0,0.1); |
| } |
| |
| /* Users List */ |
| .users-sidebar { |
| position: fixed; |
| top: 0; |
| right: -300px; |
| width: 300px; |
| height: 100%; |
| background: white; |
| box-shadow: -2px 0 10px rgba(0,0,0,0.1); |
| transition: right 0.3s ease; |
| z-index: 1000; |
| display: flex; |
| flex-direction: column; |
| } |
| |
| .users-sidebar.open { |
| right: 0; |
| } |
| |
| .sidebar-header { |
| padding: 20px; |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
| color: white; |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| } |
| |
| .close-sidebar { |
| background: none; |
| border: none; |
| color: white; |
| font-size: 20px; |
| cursor: pointer; |
| } |
| |
| .users-list { |
| flex: 1; |
| overflow-y: auto; |
| padding: 10px; |
| } |
| |
| .user-item { |
| display: flex; |
| align-items: center; |
| padding: 15px; |
| border-bottom: 1px solid #eee; |
| cursor: pointer; |
| } |
| |
| .user-item:active { |
| background: #f5f5f5; |
| } |
| |
| .user-avatar { |
| width: 40px; |
| height: 40px; |
| border-radius: 50%; |
| background: #007AFF; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| color: white; |
| font-weight: bold; |
| margin-right: 12px; |
| } |
| |
| .user-info { |
| flex: 1; |
| } |
| |
| .user-name { |
| font-weight: 500; |
| margin-bottom: 2px; |
| } |
| |
| .user-status { |
| font-size: 12px; |
| color: #4CAF50; |
| } |
| |
| .call-icon { |
| background: none; |
| border: none; |
| font-size: 20px; |
| cursor: pointer; |
| padding: 8px; |
| color: #007AFF; |
| } |
| |
| /* Call Screen */ |
| .call-screen { |
| position: fixed; |
| top: 0; |
| left: 0; |
| width: 100%; |
| height: 100%; |
| background: #1a1a1a; |
| z-index: 2000; |
| display: none; |
| flex-direction: column; |
| } |
| |
| .video-container { |
| flex: 1; |
| position: relative; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| } |
| |
| .remote-video { |
| width: 100%; |
| height: 100%; |
| object-fit: cover; |
| } |
| |
| .local-video { |
| position: absolute; |
| bottom: 20px; |
| right: 20px; |
| width: 120px; |
| height: 160px; |
| border-radius: 10px; |
| border: 2px solid white; |
| object-fit: cover; |
| } |
| |
| .call-info { |
| position: absolute; |
| top: 40px; |
| left: 0; |
| right: 0; |
| text-align: center; |
| color: white; |
| z-index: 10; |
| } |
| |
| .call-status { |
| font-size: 18px; |
| margin-bottom: 5px; |
| } |
| |
| .call-timer { |
| font-size: 24px; |
| font-weight: bold; |
| } |
| |
| .call-controls { |
| position: absolute; |
| bottom: 40px; |
| left: 0; |
| right: 0; |
| display: flex; |
| justify-content: center; |
| gap: 20px; |
| z-index: 10; |
| } |
| |
| .call-control-btn { |
| background: rgba(255,255,255,0.2); |
| border: none; |
| border-radius: 50%; |
| width: 70px; |
| height: 70px; |
| color: white; |
| font-size: 24px; |
| cursor: pointer; |
| backdrop-filter: blur(10px); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| } |
| |
| .end-call-btn { |
| background: #FF4444; |
| } |
| |
| .call-control-btn.active { |
| background: #4CAF50; |
| } |
| |
| /* Recording Overlay */ |
| .recording-overlay { |
| position: fixed; |
| top: 0; |
| left: 0; |
| width: 100%; |
| height: 100%; |
| background: rgba(0,0,0,0.8); |
| display: none; |
| justify-content: center; |
| align-items: center; |
| flex-direction: column; |
| z-index: 1000; |
| color: white; |
| } |
| |
| .recording-animation { |
| width: 100px; |
| height: 100px; |
| border: 3px solid white; |
| border-radius: 50%; |
| animation: pulse 1.5s infinite; |
| margin-bottom: 20px; |
| } |
| |
| @keyframes pulse { |
| 0% { transform: scale(0.8); opacity: 1; } |
| 50% { transform: scale(1.2); opacity: 0.7; } |
| 100% { transform: scale(0.8); opacity: 1; } |
| } |
| |
| /* Overlay */ |
| .overlay { |
| position: fixed; |
| top: 0; |
| left: 0; |
| width: 100%; |
| height: 100%; |
| background: rgba(0,0,0,0.5); |
| display: none; |
| justify-content: center; |
| align-items: center; |
| z-index: 1500; |
| } |
| |
| .call-alert { |
| background: white; |
| padding: 25px; |
| border-radius: 15px; |
| text-align: center; |
| max-width: 300px; |
| width: 90%; |
| } |
| |
| .call-alert-buttons { |
| display: flex; |
| gap: 10px; |
| margin-top: 20px; |
| } |
| |
| .alert-btn { |
| flex: 1; |
| padding: 12px; |
| border: none; |
| border-radius: 8px; |
| font-weight: bold; |
| cursor: pointer; |
| } |
| |
| .accept-btn { |
| background: #4CAF50; |
| color: white; |
| } |
| |
| .reject-btn { |
| background: #FF4444; |
| color: white; |
| } |
| |
| .no-video { |
| background: #333; |
| color: white; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| font-size: 16px; |
| } |
| |
| .connection-status { |
| position: fixed; |
| top: 10px; |
| right: 10px; |
| padding: 5px 10px; |
| border-radius: 15px; |
| font-size: 12px; |
| z-index: 3000; |
| } |
| |
| .connected { |
| background: #4CAF50; |
| color: white; |
| } |
| |
| .disconnected { |
| background: #FF4444; |
| color: white; |
| } |
| </style> |
| </head> |
| <body> |
| <!-- Connection Status --> |
| <div id="connectionStatus" class="connection-status disconnected" style="display: none;"> |
| Подключение... |
| </div> |
| |
| <!-- Login Screen --> |
| <div id="loginScreen" class="login-screen"> |
| <div style="text-align: center; margin-bottom: 30px;"> |
| <h1 style="font-size: 2.5em; margin-bottom: 10px;">💬</h1> |
| <h1>Мобильный Мессенджер</h1> |
| <p style="margin-top: 10px; opacity: 0.8;">Общение, звонки, видео</p> |
| </div> |
| <div class="login-form"> |
| <input type="text" id="usernameInput" class="login-input" placeholder="Введите ваше имя" maxlength="20"> |
| <button onclick="registerUser()" class="login-btn">Начать общение</button> |
| </div> |
| </div> |
| |
| <!-- Main Chat Screen --> |
| <div id="chatScreen" class="app-container chat-screen"> |
| <div class="header"> |
| <div class="header-info"> |
| <h2>💬 Мессенджер</h2> |
| <div class="online-info"> |
| <span id="onlineCount">0</span> пользователей онлайн |
| </div> |
| </div> |
| <div class="call-buttons"> |
| <button class="header-btn" onclick="toggleUsersSidebar()" title="Пользователи">👥</button> |
| </div> |
| </div> |
| |
| <div id="messagesContainer" class="messages-container"> |
| <div style="text-align: center; color: #666; padding: 20px;"> |
| Начните общение! |
| </div> |
| </div> |
| |
| <div class="input-area"> |
| <button class="action-btn" onclick="startVoiceRecording()" title="Голосовое сообщение">🎤</button> |
| <input type="text" id="messageInput" class="message-input" placeholder="Введите сообщение..." maxlength="500"> |
| <button class="action-btn" onclick="sendMessage()" title="Отправить">📤</button> |
| </div> |
| </div> |
| |
| <!-- Users Sidebar --> |
| <div id="usersSidebar" class="users-sidebar"> |
| <div class="sidebar-header"> |
| <h3>Пользователи онлайн</h3> |
| <button class="close-sidebar" onclick="toggleUsersSidebar()">✕</button> |
| </div> |
| <div id="usersList" class="users-list"> |
| <!-- Users will be populated here --> |
| </div> |
| </div> |
| |
| <!-- Call Screen --> |
| <div id="callScreen" class="call-screen"> |
| <div class="video-container"> |
| <div id="remoteVideoContainer" class="no-video remote-video"> |
| <div>Ожидание подключения...</div> |
| </div> |
| <video id="localVideo" class="local-video" autoplay playsinline muted></video> |
| <div class="call-info"> |
| <div class="call-status" id="callStatus">Звонок...</div> |
| <div class="call-timer" id="callTimer">00:00</div> |
| </div> |
| <div class="call-controls"> |
| <button class="call-control-btn" id="muteBtn" onclick="toggleMute()">🎤</button> |
| <button class="call-control-btn" id="videoBtn" onclick="toggleVideo()">📹</button> |
| <button class="call-control-btn end-call-btn" onclick="endCall()">📞</button> |
| </div> |
| </div> |
| </div> |
| |
| <!-- Recording Overlay --> |
| <div id="recordingOverlay" class="recording-overlay"> |
| <div class="recording-animation"></div> |
| <h3>Запись голосового сообщения...</h3> |
| <p>Нажмите для остановки</p> |
| </div> |
| |
| <!-- Incoming Call Overlay --> |
| <div id="incomingCallOverlay" class="overlay"> |
| <div class="call-alert"> |
| <h3>Входящий звонок</h3> |
| <p id="callerName">...</p> |
| <div class="call-alert-buttons"> |
| <button class="alert-btn reject-btn" onclick="rejectCall()">Отклонить</button> |
| <button class="alert-btn accept-btn" onclick="acceptCall()">Принять</button> |
| </div> |
| </div> |
| </div> |
| |
| <script src="https://cdn.socket.io/4.5.0/socket.io.min.js"></script> |
| <script> |
| let socket; |
| let currentUser = null; |
| let mediaRecorder = null; |
| let audioChunks = []; |
| let isRecording = false; |
| let localStream = null; |
| let remoteStream = null; |
| let peerConnection = null; |
| let currentCall = null; |
| let callStartTime = null; |
| let callTimerInterval = null; |
| let isMuted = false; |
| let isVideoEnabled = true; |
| let reconnectAttempts = 0; |
| const MAX_RECONNECT_ATTEMPTS = 5; |
| |
| // WebRTC configuration |
| const rtcConfig = { |
| iceServers: [ |
| { urls: 'stun:stun.l.google.com:19302' }, |
| { |
| urls: 'turn:turn.anyfirewall.com:443?transport=tcp', |
| username: 'webrtc', |
| credential: 'webrtc' |
| } |
| ] |
| }; |
| |
| function initializeSocket() { |
| // Получаем базовый URL для корректного подключения к WebSocket |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; |
| const host = window.location.host; |
| |
| socket = io({ |
| transports: ['websocket', 'polling'], |
| upgrade: true, |
| rememberUpgrade: true, |
| timeout: 10000, |
| pingTimeout: 60000, |
| pingInterval: 25000, |
| reconnection: true, |
| reconnectionAttempts: MAX_RECONNECT_ATTEMPTS, |
| reconnectionDelay: 1000, |
| reconnectionDelayMax: 5000 |
| }); |
| |
| // Socket events |
| socket.on('connect', () => { |
| console.log('Connected to server'); |
| updateConnectionStatus(true); |
| reconnectAttempts = 0; |
| |
| // Re-register if we have a current user |
| if (currentUser) { |
| socket.emit('register', currentUser.username); |
| } |
| }); |
| |
| socket.on('disconnect', (reason) => { |
| console.log('Disconnected from server:', reason); |
| updateConnectionStatus(false); |
| |
| if (reason === 'io server disconnect') { |
| // Server intentionally disconnected, try to reconnect |
| setTimeout(() => { |
| if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { |
| socket.connect(); |
| reconnectAttempts++; |
| } |
| }, 1000); |
| } |
| }); |
| |
| socket.on('connect_error', (error) => { |
| console.error('Connection error:', error); |
| updateConnectionStatus(false); |
| }); |
| |
| socket.on('registration_success', (data) => { |
| currentUser = data; |
| document.getElementById('loginScreen').style.display = 'none'; |
| document.getElementById('chatScreen').style.display = 'flex'; |
| updateConnectionStatus(true); |
| }); |
| |
| socket.on('user_list_update', (data) => { |
| document.getElementById('onlineCount').textContent = data.count; |
| updateUsersList(data.users || []); |
| }); |
| |
| socket.on('new_message', (message) => { |
| displayMessage(message); |
| }); |
| |
| socket.on('new_voice_message', (data) => { |
| displayVoiceMessage(data); |
| }); |
| |
| socket.on('incoming_call', (data) => { |
| showIncomingCall(data.from_user, data.call_id, data.isVideo); |
| }); |
| |
| socket.on('call_answered', (data) => { |
| if (data.answer) { |
| startCall(data.isVideo, false); |
| } else { |
| alert('Пользователь отклонил звонок'); |
| hideCallScreen(); |
| } |
| }); |
| |
| socket.on('call_ended', () => { |
| alert('Звонок завершен'); |
| endCall(); |
| }); |
| |
| socket.on('webrtc_offer', async (data) => { |
| console.log('Received WebRTC offer'); |
| if (!peerConnection) { |
| await createPeerConnection(false); |
| } |
| try { |
| await peerConnection.setRemoteDescription(new RTCSessionDescription(data.offer)); |
| const answer = await peerConnection.createAnswer(); |
| await peerConnection.setLocalDescription(answer); |
| |
| socket.emit('webrtc_answer', { |
| answer: answer, |
| to_user: data.from_user, |
| call_id: data.call_id |
| }); |
| } catch (error) { |
| console.error('Error handling WebRTC offer:', error); |
| } |
| }); |
| |
| socket.on('webrtc_answer', async (data) => { |
| console.log('Received WebRTC answer'); |
| if (peerConnection) { |
| try { |
| await peerConnection.setRemoteDescription(new RTCSessionDescription(data.answer)); |
| } catch (error) { |
| console.error('Error setting remote description:', error); |
| } |
| } |
| }); |
| |
| socket.on('webrtc_ice_candidate', async (data) => { |
| if (peerConnection && data.candidate) { |
| try { |
| await peerConnection.addIceCandidate(new RTCIceCandidate(data.candidate)); |
| } catch (error) { |
| console.error('Error adding ICE candidate:', error); |
| } |
| } |
| }); |
| } |
| |
| function updateConnectionStatus(connected) { |
| const statusElement = document.getElementById('connectionStatus'); |
| statusElement.style.display = 'block'; |
| if (connected) { |
| statusElement.textContent = '✓ Подключено'; |
| statusElement.className = 'connection-status connected'; |
| } else { |
| statusElement.textContent = '✗ Отключено'; |
| statusElement.className = 'connection-status disconnected'; |
| } |
| } |
| |
| // UI Functions |
| function registerUser() { |
| const username = document.getElementById('usernameInput').value.trim(); |
| if (username) { |
| if (!socket || !socket.connected) { |
| initializeSocket(); |
| } |
| socket.emit('register', username); |
| } else { |
| alert('Введите имя пользователя'); |
| } |
| } |
| |
| function sendMessage() { |
| const input = document.getElementById('messageInput'); |
| const text = input.value.trim(); |
| |
| if (text && socket && socket.connected) { |
| socket.emit('message', { |
| sender: currentUser.username, |
| text: text |
| }); |
| input.value = ''; |
| } else if (!socket || !socket.connected) { |
| alert('Нет подключения к серверу'); |
| } |
| } |
| |
| function displayMessage(message) { |
| const container = document.getElementById('messagesContainer'); |
| const messageDiv = document.createElement('div'); |
| |
| messageDiv.className = `message ${message.sender === currentUser.username ? 'own' : 'other'}`; |
| messageDiv.innerHTML = ` |
| <div class="message-sender">${message.sender}</div> |
| <div>${message.text}</div> |
| <div class="message-time">${new Date(message.timestamp).toLocaleTimeString()}</div> |
| `; |
| |
| container.appendChild(messageDiv); |
| container.scrollTop = container.scrollHeight; |
| |
| // Remove placeholder if exists |
| const placeholder = container.querySelector('div[style*="text-align: center"]'); |
| if (placeholder) { |
| placeholder.remove(); |
| } |
| } |
| |
| function displayVoiceMessage(data) { |
| const container = document.getElementById('messagesContainer'); |
| const messageDiv = document.createElement('div'); |
| |
| messageDiv.className = `message ${data.sender === currentUser.username ? 'own' : 'other'}`; |
| messageDiv.innerHTML = ` |
| <div class="message-sender">${data.sender}</div> |
| <div class="voice-message"> |
| <button class="voice-play-btn" onclick="playVoiceMessage('${data.filename}')">▶</button> |
| <div> |
| <div>Голосовое сообщение</div> |
| <div class="voice-duration">${data.duration || '0'} сек</div> |
| </div> |
| </div> |
| <div class="message-time">${new Date(data.timestamp).toLocaleTimeString()}</div> |
| `; |
| |
| container.appendChild(messageDiv); |
| container.scrollTop = container.scrollHeight; |
| } |
| |
| function updateUsersList(users) { |
| const usersList = document.getElementById('usersList'); |
| usersList.innerHTML = ''; |
| |
| const otherUsers = users.filter(user => user !== currentUser.username); |
| |
| if (otherUsers.length === 0) { |
| usersList.innerHTML = '<div style="text-align: center; padding: 20px; color: #666;">Нет других пользователей онлайн</div>'; |
| return; |
| } |
| |
| otherUsers.forEach(user => { |
| const userItem = document.createElement('div'); |
| userItem.className = 'user-item'; |
| userItem.innerHTML = ` |
| <div class="user-avatar">${user.charAt(0).toUpperCase()}</div> |
| <div class="user-info"> |
| <div class="user-name">${user}</div> |
| <div class="user-status">online</div> |
| </div> |
| <button class="call-icon" onclick="startCallWithUser('${user}', false)" title="Аудиозвонок">📞</button> |
| <button class="call-icon" onclick="startCallWithUser('${user}', true)" title="Видеозвонок">📹</button> |
| `; |
| usersList.appendChild(userItem); |
| }); |
| } |
| |
| function toggleUsersSidebar() { |
| const sidebar = document.getElementById('usersSidebar'); |
| sidebar.classList.toggle('open'); |
| } |
| |
| // Voice Messages |
| async function startVoiceRecording() { |
| if (isRecording) { |
| stopRecording(); |
| return; |
| } |
| |
| try { |
| const stream = await navigator.mediaDevices.getUserMedia({ |
| audio: { |
| echoCancellation: true, |
| noiseSuppression: true |
| } |
| }); |
| |
| mediaRecorder = new MediaRecorder(stream, { |
| mimeType: 'audio/webm;codecs=opus' |
| }); |
| |
| audioChunks = []; |
| let startTime = Date.now(); |
| |
| mediaRecorder.ondataavailable = (event) => { |
| if (event.data.size > 0) { |
| audioChunks.push(event.data); |
| } |
| }; |
| |
| mediaRecorder.onstop = async () => { |
| const duration = Math.round((Date.now() - startTime) / 1000); |
| const audioBlob = new Blob(audioChunks, { type: 'audio/webm' }); |
| |
| const reader = new FileReader(); |
| reader.onload = function() { |
| const base64Audio = reader.result.split(',')[1]; |
| |
| if (socket && socket.connected) { |
| socket.emit('voice_message', { |
| audio_data: base64Audio, |
| sender: currentUser.username, |
| duration: duration |
| }); |
| } else { |
| alert('Нет подключения для отправки голосового сообщения'); |
| } |
| }; |
| reader.readAsDataURL(audioBlob); |
| |
| stream.getTracks().forEach(track => track.stop()); |
| }; |
| |
| mediaRecorder.start(); |
| isRecording = true; |
| |
| // Show recording overlay |
| document.getElementById('recordingOverlay').style.display = 'flex'; |
| |
| } catch (error) { |
| console.error('Error recording:', error); |
| alert('Ошибка доступа к микрофону'); |
| } |
| } |
| |
| function stopRecording() { |
| if (mediaRecorder && isRecording) { |
| mediaRecorder.stop(); |
| isRecording = false; |
| document.getElementById('recordingOverlay').style.display = 'none'; |
| } |
| } |
| |
| function playVoiceMessage(filename) { |
| const audio = new Audio(`/voice/${filename}`); |
| audio.play().catch(e => console.error('Error playing audio:', e)); |
| } |
| |
| // Call Functions |
| function startCallWithUser(targetUser, isVideo) { |
| if (!socket || !socket.connected) { |
| alert('Нет подключения к серверу'); |
| return; |
| } |
| |
| currentCall = { |
| id: Date.now().toString(), |
| target: targetUser, |
| isVideo: isVideo, |
| isInitiator: true |
| }; |
| |
| socket.emit('call_request', { |
| call_id: currentCall.id, |
| from_user: currentUser.username, |
| to_user: targetUser, |
| isVideo: isVideo |
| }); |
| |
| showCallScreen(isVideo, `Звонок ${targetUser}...`); |
| } |
| |
| function showIncomingCall(fromUser, callId, isVideo) { |
| currentCall = { |
| id: callId, |
| target: fromUser, |
| isVideo: isVideo, |
| isInitiator: false |
| }; |
| |
| document.getElementById('callerName').textContent = fromUser; |
| document.getElementById('incomingCallOverlay').style.display = 'flex'; |
| } |
| |
| function acceptCall() { |
| document.getElementById('incomingCallOverlay').style.display = 'none'; |
| if (socket && socket.connected) { |
| socket.emit('call_answer', { |
| call_id: currentCall.id, |
| answer: true, |
| to_user: currentCall.target, |
| isVideo: currentCall.isVideo |
| }); |
| |
| startCall(currentCall.isVideo, true); |
| } else { |
| alert('Нет подключения к серверу'); |
| } |
| } |
| |
| function rejectCall() { |
| if (socket && socket.connected) { |
| socket.emit('call_answer', { |
| call_id: currentCall.id, |
| answer: false, |
| to_user: currentCall.target |
| }); |
| } |
| |
| document.getElementById('incomingCallOverlay').style.display = 'none'; |
| currentCall = null; |
| } |
| |
| async function startCall(isVideo, isInitiator) { |
| try { |
| showCallScreen(isVideo, isInitiator ? 'Звонок...' : 'Разговор...'); |
| |
| // Get local media stream |
| const constraints = { |
| audio: true, |
| video: isVideo ? { |
| width: { ideal: 640 }, |
| height: { ideal: 480 }, |
| frameRate: { ideal: 24 } |
| } : false |
| }; |
| |
| localStream = await navigator.mediaDevices.getUserMedia(constraints); |
| document.getElementById('localVideo').srcObject = localStream; |
| |
| // Create peer connection |
| await createPeerConnection(isInitiator); |
| |
| if (isInitiator) { |
| // Create and send offer |
| const offer = await peerConnection.createOffer(); |
| await peerConnection.setLocalDescription(offer); |
| |
| if (socket && socket.connected) { |
| socket.emit('webrtc_offer', { |
| offer: offer, |
| to_user: currentCall.target, |
| call_id: currentCall.id |
| }); |
| } |
| } |
| |
| // Start call timer |
| startCallTimer(); |
| |
| } catch (error) { |
| console.error('Error starting call:', error); |
| alert(`Ошибка начала звонка: ${error.message}`); |
| endCall(); |
| } |
| } |
| |
| async function createPeerConnection(isInitiator) { |
| peerConnection = new RTCPeerConnection(rtcConfig); |
| |
| // Add local stream tracks |
| localStream.getTracks().forEach(track => { |
| peerConnection.addTrack(track, localStream); |
| }); |
| |
| // Handle incoming stream |
| peerConnection.ontrack = (event) => { |
| console.log('Received remote track:', event.track.kind); |
| remoteStream = event.streams[0]; |
| const remoteVideo = document.getElementById('remoteVideoContainer'); |
| |
| if (event.track.kind === 'video') { |
| // Create video element for remote stream |
| const videoElement = document.createElement('video'); |
| videoElement.srcObject = remoteStream; |
| videoElement.autoplay = true; |
| videoElement.playsInline = true; |
| videoElement.className = 'remote-video'; |
| |
| remoteVideo.innerHTML = ''; |
| remoteVideo.appendChild(videoElement); |
| } else if (event.track.kind === 'audio') { |
| // For audio only, show message |
| if (!document.querySelector('.remote-video video')) { |
| remoteVideo.innerHTML = '<div>Аудиозвонок</div>'; |
| remoteVideo.className = 'no-video remote-video'; |
| } |
| } |
| }; |
| |
| // Handle ICE candidates |
| peerConnection.onicecandidate = (event) => { |
| if (event.candidate && currentCall && socket && socket.connected) { |
| socket.emit('webrtc_ice_candidate', { |
| candidate: event.candidate, |
| to_user: currentCall.target |
| }); |
| } |
| }; |
| |
| peerConnection.onconnectionstatechange = () => { |
| console.log('Connection state:', peerConnection.connectionState); |
| if (peerConnection.connectionState === 'connected') { |
| document.getElementById('callStatus').textContent = 'Разговор'; |
| } |
| }; |
| |
| peerConnection.oniceconnectionstatechange = () => { |
| console.log('ICE connection state:', peerConnection.iceConnectionState); |
| }; |
| } |
| |
| function showCallScreen(isVideo, status) { |
| document.getElementById('callScreen').style.display = 'flex'; |
| document.getElementById('callStatus').textContent = status; |
| |
| const remoteVideo = document.getElementById('remoteVideoContainer'); |
| if (!isVideo) { |
| remoteVideo.innerHTML = '<div>Аудиозвонок</div>'; |
| remoteVideo.className = 'no-video remote-video'; |
| } |
| } |
| |
| function hideCallScreen() { |
| document.getElementById('callScreen').style.display = 'none'; |
| document.getElementById('remoteVideoContainer').innerHTML = '<div>Ожидание подключения...</div>'; |
| document.getElementById('remoteVideoContainer').className = 'no-video remote-video'; |
| |
| if (callTimerInterval) { |
| clearInterval(callTimerInterval); |
| callTimerInterval = null; |
| } |
| } |
| |
| function startCallTimer() { |
| callStartTime = new Date(); |
| callTimerInterval = setInterval(() => { |
| const now = new Date(); |
| const diff = Math.floor((now - callStartTime) / 1000); |
| const minutes = Math.floor(diff / 60); |
| const seconds = diff % 60; |
| document.getElementById('callTimer').textContent = |
| `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; |
| }, 1000); |
| } |
| |
| function endCall() { |
| if (peerConnection) { |
| peerConnection.close(); |
| peerConnection = null; |
| } |
| |
| if (localStream) { |
| localStream.getTracks().forEach(track => track.stop()); |
| localStream = null; |
| } |
| |
| if (currentCall && socket && socket.connected) { |
| socket.emit('call_end', { |
| to_user: currentCall.target, |
| call_id: currentCall.id |
| }); |
| } |
| |
| hideCallScreen(); |
| currentCall = null; |
| isMuted = false; |
| isVideoEnabled = true; |
| } |
| |
| function toggleMute() { |
| if (localStream) { |
| const audioTracks = localStream.getAudioTracks(); |
| if (audioTracks.length > 0) { |
| isMuted = !isMuted; |
| audioTracks[0].enabled = !isMuted; |
| document.getElementById('muteBtn').classList.toggle('active', isMuted); |
| document.getElementById('muteBtn').textContent = isMuted ? '🎤🚫' : '🎤'; |
| } |
| } |
| } |
| |
| function toggleVideo() { |
| if (localStream) { |
| const videoTracks = localStream.getVideoTracks(); |
| if (videoTracks.length > 0) { |
| isVideoEnabled = !isVideoEnabled; |
| videoTracks[0].enabled = isVideoEnabled; |
| document.getElementById('videoBtn').classList.toggle('active', !isVideoEnabled); |
| document.getElementById('videoBtn').textContent = isVideoEnabled ? '📹' : '📹🚫'; |
| |
| // Hide/show local video |
| document.getElementById('localVideo').style.display = isVideoEnabled ? 'block' : 'none'; |
| } |
| } |
| } |
| |
| // Event listeners |
| document.getElementById('recordingOverlay').addEventListener('click', stopRecording); |
| |
| document.getElementById('messageInput').addEventListener('keypress', (e) => { |
| if (e.key === 'Enter') { |
| sendMessage(); |
| } |
| }); |
| |
| document.getElementById('usernameInput').addEventListener('keypress', (e) => { |
| if (e.key === 'Enter') { |
| registerUser(); |
| } |
| }); |
| |
| // Close sidebar when clicking outside |
| document.addEventListener('click', (e) => { |
| const sidebar = document.getElementById('usersSidebar'); |
| if (sidebar.classList.contains('open') && |
| !e.target.closest('.users-sidebar') && |
| !e.target.closest('.header-btn')) { |
| sidebar.classList.remove('open'); |
| } |
| }); |
| |
| // Initialize socket on load |
| window.addEventListener('load', function() { |
| initializeSocket(); |
| }); |
| |
| // Handle page visibility changes |
| document.addEventListener('visibilitychange', function() { |
| if (!document.hidden && (!socket || !socket.connected)) { |
| initializeSocket(); |
| } |
| }); |
| </script> |
| </body> |
| </html> |
| ''' |
|
|
| @app.route('/') |
| def index(): |
| return HTML_TEMPLATE |
|
|
| @socketio.on('connect') |
| def handle_connect(): |
| print(f'Client connected: {request.sid}') |
| emit('user_list_update', { |
| 'count': len(users), |
| 'users': [user_data['username'] for user_data in users.values()] |
| }) |
|
|
| @socketio.on('disconnect') |
| def handle_disconnect(): |
| print(f'Client disconnected: {request.sid}') |
| |
| |
| disconnected_user = None |
| for user_id, user_data in list(users.items()): |
| if user_data.get('sid') == request.sid: |
| disconnected_user = user_data['username'] |
| |
| for call_id, call_data in list(active_calls.items()): |
| if call_data['from_user'] == disconnected_user or call_data['to_user'] == disconnected_user: |
| try: |
| emit('call_ended', room=call_data['to_sid']) |
| except: |
| pass |
| try: |
| emit('call_ended', room=call_data['from_sid']) |
| except: |
| pass |
| del active_calls[call_id] |
| |
| del users[user_id] |
| break |
| |
| if disconnected_user: |
| emit('user_list_update', { |
| 'count': len(users), |
| 'users': [user_data['username'] for user_data in users.values()] |
| }, broadcast=True) |
|
|
| @socketio.on('register') |
| def handle_register(username): |
| user_id = str(uuid.uuid4()) |
| users[user_id] = { |
| 'username': username, |
| 'sid': request.sid, |
| 'joined': datetime.now().isoformat() |
| } |
| |
| emit('registration_success', { |
| 'user_id': user_id, |
| 'username': username |
| }) |
| |
| emit('user_list_update', { |
| 'count': len(users), |
| 'users': [user_data['username'] for user_data in users.values()] |
| }, broadcast=True) |
|
|
| @socketio.on('message') |
| def handle_message(data): |
| message = { |
| 'id': str(uuid.uuid4()), |
| 'sender': data['sender'], |
| 'text': data['text'], |
| 'timestamp': datetime.now().isoformat() |
| } |
| messages.append(message) |
| emit('new_message', message, broadcast=True) |
|
|
| @socketio.on('voice_message') |
| def handle_voice_message(data): |
| try: |
| audio_data = base64.b64decode(data['audio_data']) |
| filename = f"voice_{uuid.uuid4()}.webm" |
| |
| with open(filename, 'wb') as f: |
| f.write(audio_data) |
| |
| voice_data = { |
| 'filename': filename, |
| 'sender': data['sender'], |
| 'duration': data.get('duration', 0), |
| 'timestamp': datetime.now().isoformat() |
| } |
| voice_messages[filename] = voice_data |
| |
| emit('new_voice_message', voice_data, broadcast=True) |
| except Exception as e: |
| print(f"Error handling voice message: {e}") |
|
|
| @socketio.on('call_request') |
| def handle_call_request(data): |
| target_username = data.get('to_user') |
| target_sid = None |
| |
| for user_data in users.values(): |
| if user_data['username'] == target_username: |
| target_sid = user_data['sid'] |
| break |
| |
| if target_sid: |
| active_calls[data['call_id']] = { |
| 'from_user': data['from_user'], |
| 'to_user': target_username, |
| 'from_sid': request.sid, |
| 'to_sid': target_sid, |
| 'is_video': data.get('isVideo', False) |
| } |
| |
| emit('incoming_call', { |
| 'call_id': data['call_id'], |
| 'from_user': data['from_user'], |
| 'isVideo': data.get('isVideo', False) |
| }, room=target_sid) |
|
|
| @socketio.on('call_answer') |
| def handle_call_answer(data): |
| call_id = data.get('call_id') |
| if call_id in active_calls: |
| call_data = active_calls[call_id] |
| |
| if data['answer']: |
| emit('call_answered', { |
| 'answer': True, |
| 'isVideo': call_data['is_video'] |
| }, room=call_data['from_sid']) |
| else: |
| emit('call_answered', { |
| 'answer': False |
| }, room=call_data['from_sid']) |
| del active_calls[call_id] |
|
|
| @socketio.on('call_end') |
| def handle_call_end(data): |
| call_id = data.get('call_id') |
| if call_id in active_calls: |
| call_data = active_calls[call_id] |
| try: |
| emit('call_ended', room=call_data['to_sid']) |
| except: |
| pass |
| try: |
| emit('call_ended', room=call_data['from_sid']) |
| except: |
| pass |
| del active_calls[call_id] |
|
|
| @socketio.on('webrtc_offer') |
| def handle_webrtc_offer(data): |
| target_username = data.get('to_user') |
| target_sid = None |
| |
| for user_data in users.values(): |
| if user_data['username'] == target_username: |
| target_sid = user_data['sid'] |
| break |
| |
| if target_sid: |
| emit('webrtc_offer', { |
| 'offer': data['offer'], |
| 'from_user': data.get('from_user'), |
| 'call_id': data.get('call_id') |
| }, room=target_sid) |
|
|
| @socketio.on('webrtc_answer') |
| def handle_webrtc_answer(data): |
| target_username = data.get('to_user') |
| target_sid = None |
| |
| for user_data in users.values(): |
| if user_data['username'] == target_username: |
| target_sid = user_data['sid'] |
| break |
| |
| if target_sid: |
| emit('webrtc_answer', { |
| 'answer': data['answer'], |
| 'from_user': data.get('from_user') |
| }, room=target_sid) |
|
|
| @socketio.on('webrtc_ice_candidate') |
| def handle_webrtc_ice_candidate(data): |
| target_username = data.get('to_user') |
| target_sid = None |
| |
| for user_data in users.values(): |
| if user_data['username'] == target_username: |
| target_sid = user_data['sid'] |
| break |
| |
| if target_sid: |
| emit('webrtc_ice_candidate', { |
| 'candidate': data['candidate'], |
| 'from_user': data.get('from_user') |
| }, room=target_sid) |
|
|
| @app.route('/voice/<filename>') |
| def get_voice_message(filename): |
| if filename in voice_messages: |
| return send_file(filename, mimetype='audio/webm') |
| return 'File not found', 404 |
|
|
| def cleanup_temp_files(): |
| """Очистка временных файлов""" |
| import glob |
| for filename in glob.glob("voice_*.webm"): |
| try: |
| os.remove(filename) |
| except: |
| pass |
|
|
| if __name__ == '__main__': |
| try: |
| print("Запуск мобильного мессенджера...") |
| print("Порт: 7860") |
| socketio.run( |
| app, |
| host='0.0.0.0', |
| port=7860, |
| debug=False, |
| allow_unsafe_werkzeug=True |
| ) |
| finally: |
| cleanup_temp_files() |