Spaces:
Running
Running
from flask import Flask, render_template_string, request, redirect, url_for, session | |
import random | |
import string | |
import json | |
import os | |
from flask_socketio import SocketIO, join_room, leave_room, emit | |
import hashlib | |
app = Flask(__name__) | |
app.config['SECRET_KEY'] = 'your-very-secret-key-here' # ОЧЕНЬ ВАЖНО: смените на реальный секретный ключ! | |
socketio = SocketIO(app) | |
# Пути к JSON-файлам (лучше использовать абсолютные пути или пути относительно app.root_path) | |
ROOMS_DB = os.path.join(app.root_path, 'rooms.json') | |
USERS_DB = os.path.join(app.root_path, 'users.json') | |
# Загрузка данных из JSON (с обработкой ошибок) | |
def load_json(file_path, default={}): | |
try: | |
if os.path.exists(file_path): | |
with open(file_path, 'r', encoding='utf-8') as f: | |
return json.load(f) | |
return default | |
except (FileNotFoundError, json.JSONDecodeError) as e: | |
print(f"Error loading JSON from {file_path}: {e}") | |
return default | |
# Сохранение данных в JSON (с обработкой ошибок) | |
def save_json(file_path, data): | |
try: | |
with open(file_path, 'w', encoding='utf-8') as f: | |
json.dump(data, f, indent=4, ensure_ascii=False) | |
except OSError as e: | |
print(f"Error saving JSON to {file_path}: {e}") | |
# Инициализация баз данных | |
rooms = load_json(ROOMS_DB) | |
users = load_json(USERS_DB) | |
# Генерация токена | |
def generate_token(): | |
return ''.join(random.choices(string.ascii_letters + string.digits, k=15)) | |
# Хеширование пароля (более безопасно использовать bcrypt или scrypt) | |
def hash_password(password): | |
return hashlib.sha256(password.encode('utf-8')).hexdigest() | |
# Главная страница (регистрация/вход) | |
def index(): | |
if 'username' in session: | |
return redirect(url_for('dashboard')) | |
if request.method == 'POST': | |
action = request.form.get('action') | |
username = request.form.get('username') | |
password = request.form.get('password') | |
if action == 'register': | |
if username in users: | |
return "Пользователь уже существует", 400 | |
# ВАЖНО: Хешируйте пароли перед сохранением! | |
users[username] = hash_password(password) | |
save_json(USERS_DB, users) | |
session['username'] = username | |
return redirect(url_for('dashboard')) | |
elif action == 'login': | |
if username in users and users[username] == hash_password(password): | |
session['username'] = username | |
return redirect(url_for('dashboard')) | |
return "Неверный логин или пароль", 401 | |
return render_template_string('''<!DOCTYPE html> | |
<html lang="ru"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Видеоконференция</title> | |
<style> | |
:root { | |
--primary-color: #6200ee; /* Основной цвет */ | |
--secondary-color: #3700b3; /* Вторичный цвет */ | |
--background-color: #ffffff; /* Цвет фона */ | |
--surface-color: #f5f5f5; /* Цвет поверхностей (карточки, формы) */ | |
--text-color: #333333; /* Основной цвет текста */ | |
--error-color: #b00020; /* Цвет ошибки */ | |
--font-family: 'Roboto', sans-serif; /* Шрифт */ | |
--border-radius: 12px; /* Радиус скругления */ | |
--box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1); /* Тень */ | |
} | |
body { | |
font-family: var(--font-family); | |
background-color: var(--background-color); | |
color: var(--text-color); | |
margin: 0; | |
padding: 0; | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
min-height: 100vh; | |
} | |
.container { | |
background-color: var(--surface-color); | |
padding: 2rem; | |
border-radius: var(--border-radius); | |
box-shadow: var(--box-shadow); | |
width: 90%; | |
max-width: 400px; | |
text-align: center; | |
} | |
h1 { | |
font-size: 2rem; | |
margin-bottom: 1.5rem; | |
color: var(--primary-color); | |
} | |
input, button { | |
display: block; | |
width: 100%; | |
padding: 0.75rem; | |
margin-bottom: 1rem; | |
border: 1px solid #ccc; | |
border-radius: var(--border-radius); | |
font-size: 1rem; | |
box-sizing: border-box; | |
transition: border-color 0.3s ease; | |
} | |
input:focus { | |
outline: none; | |
border-color: var(--primary-color); | |
} | |
button { | |
background-color: var(--primary-color); | |
color: white; | |
cursor: pointer; | |
border: none; | |
font-weight: 500; | |
transition: background-color 0.3s ease; | |
} | |
button:hover { | |
background-color: var(--secondary-color); | |
} | |
button:active { | |
opacity: 0.8; /* Немного уменьшаем прозрачность при нажатии */ | |
} | |
.error-message { | |
color: var(--error-color); | |
margin-top: 0.5rem; | |
} | |
@media (prefers-color-scheme: dark) { | |
:root { | |
--background-color: #121212; | |
--surface-color: #1e1e1e; | |
--text-color: #ffffff; | |
} | |
} | |
</style> | |
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet"> | |
</head> | |
<body> | |
<div class="container"> | |
<h1>Видеоконференция</h1> | |
<form method="post"> | |
<input type="text" name="username" placeholder="Логин" required> | |
<input type="password" name="password" placeholder="Пароль" required> | |
<button type="submit" name="action" value="login">Войти</button> | |
<button type="submit" name="action" value="register">Зарегистрироваться</button> | |
</form> | |
</div> | |
</body> | |
</html>''') | |
# Панель управления | |
def dashboard(): | |
if 'username' not in session: | |
return redirect(url_for('index')) | |
if request.method == 'POST': | |
action = request.form.get('action') | |
if action == 'create': | |
token = generate_token() | |
# Добавляем создателя комнаты как админа | |
rooms[token] = {'users': [], 'max_users': 5, 'admin': session['username']} | |
save_json(ROOMS_DB, rooms) | |
return redirect(url_for('room', token=token)) | |
elif action == 'join': | |
token = request.form.get('token') | |
if token in rooms and len(rooms[token]['users']) < rooms[token]['max_users']: | |
return redirect(url_for('room', token=token)) | |
return "Комната не найдена или переполнена", 404 | |
return render_template_string('''<!DOCTYPE html> | |
<html lang="ru"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Панель управления</title> | |
<style> | |
:root { | |
--primary-color: #6200ee; | |
--secondary-color: #3700b3; | |
--background-color: #ffffff; | |
--surface-color: #f5f5f5; | |
--text-color: #333333; | |
--accent-color: #03dac6; /* Цвет акцента (для кнопок, например) */ | |
--font-family: 'Roboto', sans-serif; | |
--border-radius: 12px; | |
--box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1); | |
} | |
body { | |
font-family: var(--font-family); | |
background-color: var(--background-color); | |
color: var(--text-color); | |
margin: 0; | |
padding: 0; | |
display: flex; | |
flex-direction: column; | |
align-items: center; | |
min-height: 100vh; | |
} | |
.container { | |
background-color: var(--surface-color); | |
padding: 2rem; | |
border-radius: var(--border-radius); | |
box-shadow: var(--box-shadow); | |
width: 90%; | |
max-width: 400px; | |
text-align: center; | |
margin-top: 2rem; | |
} | |
h1 { | |
font-size: 2rem; | |
margin-bottom: 1.5rem; | |
color: var(--primary-color); | |
} | |
input, button { | |
display: block; | |
width: 100%; | |
padding: 0.75rem; | |
margin-bottom: 1rem; | |
border: 1px solid #ccc; | |
border-radius: var(--border-radius); | |
font-size: 1rem; | |
box-sizing: border-box; | |
transition: border-color 0.3s ease; | |
} | |
input:focus { | |
outline: none; | |
border-color: var(--primary-color); | |
} | |
button { | |
background-color: var(--primary-color); | |
color: white; | |
cursor: pointer; | |
border: none; | |
font-weight: 500; | |
transition: background-color 0.3s ease; | |
} | |
button:hover { | |
background-color: var(--secondary-color); | |
} | |
button:active { | |
opacity: 0.8; /* Немного уменьшаем прозрачность при нажатии */ | |
} | |
.logout-button { | |
background-color: var(--accent-color); /* Другой цвет для кнопки выхода */ | |
margin-top: 1rem; | |
transition: background-color 0.3s ease; | |
} | |
.logout-button:hover { | |
filter: brightness(0.9); /*Затемнение при наведении*/ | |
} | |
@media (prefers-color-scheme: dark) { | |
:root { | |
--background-color: #121212; | |
--surface-color: #1e1e1e; | |
--text-color: #ffffff; | |
} | |
} | |
</style> | |
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet"> | |
</head> | |
<body> | |
<div class="container"> | |
<h1>Добро пожаловать, {{ session['username'] }}</h1> | |
<form method="post"> | |
<button type="submit" name="action" value="create">Создать комнату</button> | |
</form> | |
<form method="post"> | |
<input type="text" name="token" placeholder="Введите токен комнаты" required> | |
<button type="submit" name="action" value="join">Войти в комнату</button> | |
</form> | |
<form action="/logout" method="post"> | |
<button class="logout-button" type="submit">Выйти</button> | |
</form> | |
</div> | |
</body> | |
</html>''', session=session) | |
# Выход из системы | |
def logout(): | |
session.pop('username', None) | |
return redirect(url_for('index')) | |
# Страница комнаты | |
def room(token): | |
if 'username' not in session: | |
return redirect(url_for('index')) | |
is_admin = False # Default to False | |
if token in rooms: | |
is_admin = rooms[token].get('admin') == session['username'] | |
else: | |
return redirect(url_for('dashboard')) # Keep this here | |
return render_template_string('''<!DOCTYPE html> | |
<html lang="ru"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Комната {{ token }}</title> | |
<style> | |
:root { | |
--primary-color: #6200ee; | |
--secondary-color: #3700b3; | |
--background-color: #ffffff; | |
--surface-color: #f0f0f0; /* Светлый фон для видео */ | |
--text-color: #333333; | |
--font-family: 'Roboto', sans-serif; | |
--border-radius: 12px; | |
--box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1); | |
--control-bg-color: #eaeaea; /* Цвет фона для элементов управления */ | |
--control-icon-color: #666666; /* Цвет иконок */ | |
} | |
body { | |
font-family: var(--font-family); | |
background-color: var(--background-color); | |
color: var(--text-color); | |
margin: 0; | |
padding: 0; | |
} | |
h1 { | |
font-size: 1.8rem; | |
text-align: center; | |
margin: 1rem 0; | |
color: var(--primary-color); | |
} | |
#users { | |
text-align: center; | |
margin-bottom: 1rem; | |
font-size: 1rem; | |
color: var(--secondary-color); | |
} | |
.video-grid-wrapper { | |
display: flex; | |
justify-content: center; /* Центрируем сетку по горизонтали */ | |
padding: 1rem; | |
} | |
.video-grid { | |
display: grid; | |
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); | |
gap: 1rem; | |
max-width: 1200px; /* Ограничиваем максимальную ширину сетки */ | |
width: 100%; /* Растягиваем на всю доступную ширину */ | |
} | |
video { | |
width: 100%; | |
height: auto; | |
background: black; /* Фон для неотрендеренного видео */ | |
border-radius: var(--border-radius); | |
box-shadow: var(--box-shadow); | |
object-fit: cover; /* Важно для правильного отображения */ | |
display: block; /* Убирает лишние отступы */ | |
transform: scaleX(1); /* Убираем отзеркаливание */ | |
} | |
/* Стили для контейнера видео */ | |
.video-container { | |
position: relative; /* Для позиционирования элементов внутри */ | |
overflow: hidden; /* Скрываем всё, что выходит за границы */ | |
border-radius: var(--border-radius); /* Скругление углов */ | |
} | |
/* Индикатор пользователя (имя) */ | |
.user-indicator { | |
position: absolute; | |
bottom: 0.5rem; | |
left: 0.5rem; | |
background-color: rgba(0, 0, 0, 0.6); /* Полупрозрачный фон */ | |
color: white; | |
padding: 0.2rem 0.5rem; | |
border-radius: 5px; | |
font-size: 0.8rem; | |
z-index: 10; /* Поверх видео */ | |
} | |
.controls { | |
position: absolute; | |
top: 0.5rem; | |
right: 0.5rem; | |
display: flex; | |
gap: 0.5rem; | |
z-index: 20; | |
} | |
.controls button { | |
background-color: var(--control-bg-color); | |
color: var(--control-icon-color); | |
border: none; | |
border-radius: 50%; /* Круглые кнопки */ | |
padding: 0.4rem; | |
cursor: pointer; | |
font-size: 1rem; | |
width: 2rem; | |
height: 2rem; | |
display: flex; /* Центрируем иконки */ | |
justify-content: center; | |
align-items: center; | |
transition: background-color 0.2s, color 0.2s; /* Плавные переходы */ | |
} | |
.controls button:hover { | |
background-color: var(--primary-color); | |
color: white; | |
} | |
/* Админские кнопки */ | |
.admin-controls { | |
position: absolute; | |
top: 0.5rem; | |
left: 0.5rem; | |
display: flex; | |
gap: 0.3rem; | |
z-index: 15; | |
} | |
.admin-controls button { | |
background-color: rgba(255, 0, 0, 0.7); /* Полупрозрачный красный */ | |
color: white; | |
border: none; | |
border-radius: 5px; | |
padding: 0.2rem 0.4rem; | |
font-size: 0.7rem; | |
cursor: pointer; | |
transition: background-color 0.2s; | |
} | |
.admin-controls button:hover { | |
background-color: rgba(255, 0, 0, 0.9); | |
} | |
.leave-button { | |
display: block; | |
width: 80%; | |
max-width: 300px; | |
padding: 0.75rem; | |
margin: 1rem auto; | |
background-color: var(--primary-color); | |
color: white; | |
border: none; | |
border-radius: var(--border-radius); | |
cursor: pointer; | |
font-weight: 500; | |
transition: background-color 0.3s ease; | |
} | |
.leave-button:hover { | |
background-color: var(--secondary-color); | |
} | |
.leave-button:active { | |
opacity: 0.8; | |
} | |
/* Адаптивность */ | |
@media (max-width: 600px) { | |
.video-grid { | |
grid-template-columns: 1fr; /* Одно видео в ряд на мобильных */ | |
} | |
video { | |
max-height: 300px; /* Ограничение по высоте на мобильных */ | |
} | |
.controls { | |
top: auto; /* Переносим кнопки вниз на мобильных */ | |
bottom: 0.5rem; | |
left: 0.5rem; | |
right: auto; | |
} | |
.admin-controls { | |
top: auto; /* Переносим админские кнопки */ | |
bottom: 0.5rem; | |
left: auto; | |
right: 0.5rem; | |
} | |
} | |
@media (prefers-color-scheme: dark) { | |
:root { | |
--background-color: #121212; | |
--surface-color: #1e1e1e; | |
--text-color: #ffffff; | |
--control-bg-color: #333; | |
--control-icon-color: #eee; | |
} | |
} | |
</style> | |
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet"> | |
<!-- Добавляем Material Icons --> | |
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.5.1/socket.io.js"></script> | |
</head> | |
<body> | |
<h1>Комната: {{ token }}</h1> | |
<div id="users"></div> | |
<div class="video-grid-wrapper"> | |
<div class="video-grid" id="video-grid"></div> | |
</div> | |
<button class = "leave-button" onclick="leaveRoom()">Покинуть комнату</button> | |
<script> | |
const socket = io(); | |
const token = '{{ token }}'; | |
const username = '{{ session['username'] }}'; | |
const isAdmin = {{ is_admin|tojson }}; // Передаем флаг админа | |
let localStream; | |
const peers = {}; | |
const iceConfig = { | |
iceServers: [{ urls: 'stun:stun.l.google.com:19032' }] | |
}; | |
// Функция для создания кнопок управления | |
function createControls(user, videoContainer) { | |
const controls = document.createElement('div'); | |
controls.classList.add('controls'); | |
const muteAudioButton = document.createElement('button'); | |
muteAudioButton.innerHTML = '<i class="material-icons">mic</i>'; // Иконка микрофона | |
muteAudioButton.title = "Вкл/Выкл микрофон"; | |
muteAudioButton.dataset.muted = 'false'; | |
muteAudioButton.onclick = () => toggleAudio(user, muteAudioButton); | |
controls.appendChild(muteAudioButton); | |
const muteVideoButton = document.createElement('button'); | |
muteVideoButton.innerHTML = '<i class="material-icons">videocam</i>'; // Иконка камеры | |
muteVideoButton.title = "Вкл/Выкл камеру"; | |
muteVideoButton.dataset.videoMuted = 'false'; | |
muteVideoButton.onclick = () => toggleVideo(user, muteVideoButton); | |
controls.appendChild(muteVideoButton); | |
videoContainer.appendChild(controls); | |
// Админские кнопки (только если текущий пользователь - админ) | |
if (isAdmin && user !== username) { | |
createAdminControls(user, videoContainer); | |
} | |
} | |
// Функция для создания админских кнопок | |
function createAdminControls(targetUser, videoContainer) { | |
const adminControls = document.createElement('div'); | |
adminControls.classList.add('admin-controls'); | |
const muteUserButton = document.createElement('button'); | |
muteUserButton.innerHTML = 'Mute'; | |
muteUserButton.title = `Mute ${targetUser}`; | |
muteUserButton.onclick = () => { | |
socket.emit('admin_mute', { token, targetUser, byUser: username }); | |
}; | |
adminControls.appendChild(muteUserButton); | |
videoContainer.appendChild(adminControls); | |
} | |
// Функции управления звуком/видео (локально) | |
function toggleAudio(user, button) { | |
const muted = button.dataset.muted === 'true'; | |
button.dataset.muted = !muted; // Инвертируем состояние | |
button.innerHTML = `<i class="material-icons">${!muted ? 'mic' : 'mic_off'}</i>`; | |
if (user === username) { | |
localStream.getAudioTracks().forEach(track => track.enabled = muted); // Меняем состояние трека | |
} else if (peers[user]) { | |
// Для удаленных пользователей, у нас нет прямого доступа к их трекам. | |
// Вместо этого можно было бы отправить сообщение через сокет, чтобы *попросить* их выключить звук, | |
// но это уже выходит за рамки простой логики "вкл/выкл". | |
// console.log("Нельзя напрямую управлять звуком удаленного пользователя."); | |
} | |
} | |
function toggleVideo(user, button) { | |
const muted = button.dataset.videoMuted === 'true'; | |
button.dataset.videoMuted = !muted; | |
button.innerHTML = `<i class="material-icons">${!muted ? 'videocam' : 'videocam_off'}</i>`; | |
if (user === username) { | |
localStream.getVideoTracks().forEach(track => track.enabled = muted); | |
} // Для удалённых пользователей аналогично toggleAudio | |
} | |
// Получение локального видео/аудио потока | |
navigator.mediaDevices.getUserMedia({ video: true, audio: true }) | |
.then(stream => { | |
localStream = stream; | |
addVideoStream(stream, username, true); | |
socket.emit('join', { token: token, username: username }); | |
}) | |
.catch(err => console.error('Ошибка доступа к камере/микрофону:', err)); | |
// Добавление видео в сетку | |
function addVideoStream(stream, user, muted = false) { | |
console.log('Добавление видео для', user); | |
const existingVideo = document.querySelector(`video[data-user="${user}"]`); | |
if (existingVideo) return; | |
const videoContainer = document.createElement('div'); // Контейнер | |
videoContainer.classList.add('video-container'); | |
const video = document.createElement('video'); | |
video.srcObject = stream; | |
video.setAttribute('playsinline', ''); | |
video.setAttribute('autoplay', ''); | |
video.addEventListener('loadedmetadata', () => { | |
video.play().catch(e => console.error('Autoplay error:', e)); | |
}); | |
if (muted) video.muted = true; | |
video.dataset.user = user; | |
// Добавляем индикатор пользователя | |
const userIndicator = document.createElement('div'); | |
userIndicator.classList.add('user-indicator'); | |
userIndicator.textContent = user; | |
videoContainer.appendChild(userIndicator); | |
createControls(user, videoContainer); // Создаем кнопки управления | |
videoContainer.appendChild(video); // Видео внутрь контейнера | |
document.getElementById('video-grid').appendChild(videoContainer); // Добавляем контейнер | |
} | |
// Создание соединения | |
function createPeerConnection(user) { | |
if (peers[user]) { | |
return peers[user].peerConnection; // Return existing connection | |
} | |
console.log('Создание RTCPeerConnection для', user); | |
const peerConnection = new RTCPeerConnection(iceConfig); | |
peers[user] = { peerConnection: peerConnection, iceCandidates: [] }; | |
localStream.getTracks().forEach(track => peerConnection.addTrack(track, localStream)); | |
peerConnection.ontrack = event => { | |
console.log('Получен поток от', user); | |
addVideoStream(event.streams[0], user); | |
}; | |
// Обработка состояния соединения | |
peerConnection.oniceconnectionstatechange = () => { | |
console.log(`ICE connection state changed to ${peerConnection.iceConnectionState} for user ${user}`); | |
if (peerConnection.iceConnectionState === 'failed' || peerConnection.iceConnectionState === 'disconnected') { | |
console.warn(`Connection with ${user} failed or disconnected.`); | |
// Можно добавить логику переподключения или удаления пользователя из UI | |
if(peers[user]){ | |
peers[user].peerConnection.close(); | |
delete peers[user]; | |
const video = document.querySelector(`video[data-user="${user}"]`); | |
if (video) video.parentElement.remove(); // Удалять вместе с контейнером! | |
} | |
} | |
}; | |
peerConnection.onicecandidate = event => { | |
if (event.candidate) { | |
console.log('Отправка ICE-кандидата для', user); | |
socket.emit('signal', { | |
token: token, | |
from: username, | |
to: user, | |
signal: { type: 'candidate', candidate: event.candidate } | |
}); | |
} | |
}; | |
return peerConnection; | |
} | |
// Обработка входящих сигналов | |
socket.on('signal', data => { | |
if (data.from === username) return; | |
console.log('Получен сигнал от', data.from, ':', data.signal.type); | |
let peerEntry = peers[data.from]; | |
if (!peerEntry) { | |
createPeerConnection(data.from); | |
peerEntry = peers[data.from]; | |
} | |
let peerConnection = peerEntry.peerConnection; | |
if (data.signal.type === 'offer') { | |
console.log('Обработка предложения от', data.from); | |
peerConnection.setRemoteDescription(new RTCSessionDescription(data.signal)) | |
.then(() => peerConnection.createAnswer()) | |
.then(answer => peerConnection.setLocalDescription(answer)) | |
.then(() => { | |
socket.emit('signal', { | |
token: token, | |
from: username, | |
to: data.from, | |
signal: peerConnection.localDescription | |
}); | |
//Добавление ICE-кандидатов из буффера | |
while (peerEntry.iceCandidates.length > 0) { | |
const candidate = peerEntry.iceCandidates.shift(); | |
peerConnection.addIceCandidate(new RTCIceCandidate(candidate)) | |
.catch(err => console.error("Error adding ice candidate from buffer", err)); | |
} | |
}) | |
.catch(err => console.error('Ошибка обработки предложения:', err)); | |
} else if (data.signal.type === 'answer') { | |
console.log('Обработка ответа от', data.from); | |
peerConnection.setRemoteDescription(new RTCSessionDescription(data.signal)) | |
.then(() => { | |
//Добавление ICE-кандидатов из буффера | |
while (peerEntry.iceCandidates.length > 0) { | |
const candidate = peerEntry.iceCandidates.shift(); | |
peerConnection.addIceCandidate(new RTCIceCandidate(candidate)) | |
.catch(err => console.error("Error adding ice candidate from buffer", err)); | |
} | |
}) | |
.catch(err => console.error('Ошибка установки ответа:', err)); | |
} else if (data.signal.type === 'candidate') { | |
console.log('Обработка ICE-кандидата от', data.from); | |
if (peerConnection.remoteDescription) { | |
peerConnection.addIceCandidate(new RTCIceCandidate(data.signal.candidate)) | |
.catch(err => console.error('Ошибка добавления ICE-кандидата:', err)); | |
} else { | |
// Если remote description еще не установлен, добавляем в буфер | |
peerEntry.iceCandidates.push(data.signal.candidate); | |
console.log("Ice candidate buffered for", data.from) | |
} | |
} | |
}); | |
socket.on('user_joined', data => { | |
console.log('Пользователь', data.username, 'присоединился'); | |
document.getElementById('users').innerText = 'Пользователи: ' + data.users.join(', '); | |
if (data.username !== username) { | |
const peerConnection = createPeerConnection(data.username); | |
// Создание предложения (offer), только если мы инициатор | |
peerConnection.createOffer() | |
.then(offer => peerConnection.setLocalDescription(offer)) | |
.then(() => { | |
socket.emit('signal', { | |
token: token, | |
from: username, | |
to: data.username, | |
signal: peerConnection.localDescription | |
}); | |
}) | |
.catch(err => console.error('Ошибка создания предложения:', err)); | |
} | |
}); | |
socket.on('user_left', data => { | |
console.log('Пользователь', data.username, 'покинул комнату'); | |
document.getElementById('users').innerText = 'Пользователи: ' + data.users.join(', '); | |
if (peers[data.username]) { | |
peers[data.username].peerConnection.close(); | |
delete peers[data.username]; | |
const video = document.querySelector(`video[data-user="${data.username}"]`); | |
if (video) video.parentElement.remove(); // Удаляем вместе с контейнером! | |
} | |
}); | |
socket.on('init_users', data => { | |
console.log('Инициализация пользователей:', data.users); | |
data.users.forEach(user => { | |
if (user !== username) { | |
createPeerConnection(user); // Создаем RTCPeerConnection для всех, *кроме себя* | |
} | |
}); | |
}); | |
socket.on('admin_muted', data => { // Обработка события мута от админа | |
if (data.targetUser === username) { | |
// Выключаем микрофон (локально) | |
localStream.getAudioTracks().forEach(track => track.enabled = false); | |
// Обновляем состояние кнопки (если есть) | |
const muteButton = document.querySelector(`.controls button[data-muted][data-user="${username}"]`); | |
if (muteButton) { | |
muteButton.dataset.muted = 'true'; | |
muteButton.innerHTML = '<i class="material-icons">mic_off</i>'; | |
} | |
} | |
}); | |
function leaveRoom() { | |
socket.emit('leave', { token: token, username: username }); | |
if (localStream) { // Проверка, что localStream существует | |
localStream.getTracks().forEach(track => track.stop()); | |
} | |
for (let user in peers) { | |
peers[user].peerConnection.close(); | |
} | |
window.location.href = '/dashboard'; | |
} | |
</script> | |
</body> | |
</html>''', token=token, session=session, is_admin=is_admin) | |
# WebSocket события | |
def handle_join(data): | |
token = data['token'] | |
username = data['username'] | |
if token in rooms and len(rooms[token]['users']) < rooms[token]['max_users']: | |
join_room(token) | |
if username not in rooms[token]['users']: | |
rooms[token]['users'].append(username) | |
save_json(ROOMS_DB, rooms) | |
emit('user_joined', {'username': username, 'users': rooms[token]['users']}, room=token) | |
emit('init_users', {'users': rooms[token]['users']}, to=request.sid) | |
else: | |
# Отправляем сообщение об ошибке, если комната заполнена | |
emit('error_message', {'message': 'Комната переполнена'}, to=request.sid) | |
def handle_leave(data): | |
token = data['token'] | |
username = data['username'] | |
if token in rooms and username in rooms[token]['users']: | |
leave_room(token) | |
rooms[token]['users'].remove(username) | |
# Если админ выходит, комнату нужно пометить, или назначить нового админа. Простейший вариант - удалить. | |
if rooms[token]['admin'] == username: | |
del rooms[token] | |
save_json(ROOMS_DB, rooms) | |
emit('user_left', {'username': username, 'users': rooms[token]['users']}, room=token) | |
def handle_signal(data): | |
emit('signal', data, room=data['token'], skip_sid=request.sid) | |
def handle_admin_mute(data): | |
token = data['token'] | |
target_user = data['targetUser'] | |
by_user = data['byUser'] # Кто замьютил | |
# Проверяем, является ли отправитель админом комнаты | |
if token in rooms and rooms[token].get('admin') == by_user: | |
emit('admin_muted', {'targetUser': target_user}, room=token) # Отправляем событие всем в комнате | |
if __name__ == '__main__': | |
socketio.run(app, host='0.0.0.0', port=7860, debug=True, allow_unsafe_werkzeug=True) |