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-файлам | |
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 | |
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)) | |
def hash_password(password): | |
return hashlib.sha256(password.encode('utf-8')).hexdigest() | |
# ... (остальные функции: index, dashboard, logout - без изменений) ... | |
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'], 'video_url': '', 'video_state': {'playing': False, 'time': 0}} | |
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')) | |
if token not in rooms: | |
return redirect(url_for('dashboard')) | |
is_admin = rooms[token]['admin'] == session['username'] | |
video_url = rooms[token]['video_url'] | |
video_state = rooms[token]['video_state'] | |
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: #4CAF50; /* Зеленый */ | |
--secondary-color: #388E3C; /* Темно-зеленый */ | |
--background-color: #f0f0f0; | |
--surface-color: #ffffff; | |
--text-color: #333333; | |
--font-family: 'Roboto', sans-serif; | |
--border-radius: 12px; | |
--box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.15); /* Более выраженная тень */ | |
--control-bg-color: #e0e0e0; | |
--control-icon-color: #555555; | |
} | |
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; | |
} | |
h1 { | |
font-size: 2rem; | |
text-align: center; | |
margin: 1.5rem 0; /* Увеличенные отступы */ | |
color: var(--primary-color); | |
} | |
#users { | |
text-align: center; | |
margin-bottom: 1rem; | |
font-size: 1.1rem; /* Немного увеличенный шрифт */ | |
color: var(--secondary-color); | |
font-weight: 500; /* Полужирное начертание */ | |
} | |
.main-container { | |
display: flex; | |
flex-direction: row; /* Элементы в ряд */ | |
justify-content: space-between; /* Распределяем пространство */ | |
align-items: flex-start; /* Выравниваем по верхнему краю */ | |
width: 95%; /* Ширина основного контейнера */ | |
max-width: 1400px; /* Максимальная ширина */ | |
margin: 0 auto; /* Центрируем */ | |
gap: 2rem; /* Отступ между секциями */ | |
} | |
.video-section { | |
flex: 2; /* Видео-секция занимает большую часть */ | |
display: flex; | |
flex-direction: column; /* Элементы внутри секции - вертикально */ | |
align-items: center; | |
} | |
#shared-video-container { | |
width: 100%; | |
max-width: 800px; | |
margin-bottom: 1.5rem; /* Отступ снизу */ | |
position: relative; | |
border-radius: var(--border-radius); | |
box-shadow: var(--box-shadow); | |
overflow: hidden; /* Скрываем выходящие за границы элементы */ | |
} | |
#shared-video { | |
width: 100%; | |
height: auto; | |
display: block; | |
border-radius: var(--border-radius); /* Скругление и для iframe */ | |
} | |
#video-controls { | |
position: absolute; | |
bottom: 10px; | |
left: 0; | |
width: 100%; | |
display: flex; | |
justify-content: center; | |
gap: 10px; | |
z-index: 30; | |
background-color: rgba(0, 0, 0, 0.5); /* Полупрозрачный фон */ | |
padding: 5px 0; /* Отступы */ | |
border-bottom-left-radius: var(--border-radius); /* Скругление углов */ | |
border-bottom-right-radius: var(--border-radius); /* Скругление углов */ | |
} | |
#video-controls button { | |
background-color: transparent; /* Прозрачный фон */ | |
color: white; | |
border: none; | |
border-radius: 50%; | |
padding: 0.4rem; | |
cursor: pointer; | |
font-size: 1.2rem; /* Увеличенный размер иконок */ | |
width: 2.5rem; /* Увеличенный размер кнопок */ | |
height: 2.5rem; | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
transition: background-color 0.2s; | |
} | |
#video-controls button:hover{ | |
background-color: rgba(255,255,255, 0.2); /* Эффект при наведении */ | |
} | |
.video-grid-wrapper { | |
flex: 1; /* меньше места */ | |
overflow-y: auto; /* Добавляем прокрутку */ | |
max-height: 80vh;/* Ограничиваем высоту */ | |
padding: 1rem; | |
border-radius: var(--border-radius); | |
background-color: var(--surface-color); | |
box-shadow: var(--box-shadow); | |
} | |
.video-grid { | |
display: grid; | |
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); /* Меньший размер для превью */ | |
gap: 1rem; | |
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); | |
} | |
.admin-video-controls { | |
display: flex; | |
align-items: center; | |
gap: 0.5rem; | |
margin-bottom: 1rem; /* Отступ от поля ввода */ | |
} | |
#video-url-input { | |
padding: 0.5rem; | |
border: 1px solid #ccc; | |
border-radius: var(--border-radius); | |
font-size: 0.9rem; | |
width: 100%; /* Растягиваем на всю доступную ширину */ | |
box-sizing: border-box; /* отступы и границы */ | |
margin-right: 0.5rem; /* Отступ справа */ | |
} | |
#set-video-url { | |
background-color: var(--primary-color); | |
color: white; | |
border: none; | |
border-radius: var(--border-radius); | |
padding: 0.5rem 1rem; | |
cursor: pointer; | |
font-size: 0.9rem; | |
transition: background-color 0.2s; | |
white-space: nowrap; /* Запрещаем перенос текста */ | |
} | |
#set-video-url:hover{ | |
background-color: var(--secondary-color) | |
} | |
.leave-button { | |
display: block; /* Блочный элемент */ | |
width: auto; /* Ширина по содержимому */ | |
padding: 0.75rem 1.5rem; /* отступы */ | |
margin: 1rem auto; | |
background-color: #f44336; /* Красный цвет */ | |
color: white; | |
border: none; | |
border-radius: var(--border-radius); | |
cursor: pointer; | |
font-weight: 500; | |
transition: background-color 0.3s ease; | |
white-space: nowrap; /* Запрещаем перенос */ | |
} | |
.leave-button:hover { | |
background-color: #d32f2f; /* Темно-красный */ | |
} | |
.leave-button:active{ | |
opacity: 0.8; | |
} | |
@media (max-width: 768px) { | |
.main-container { | |
flex-direction: column; /* На мобильных устройствах - вертикальное расположение */ | |
} | |
.video-grid-wrapper { | |
max-height: 40vh; /* Меньшая высота на мобильных */ | |
order: -1; /* Перемещаем превью наверх */ | |
margin-bottom: 1rem; /* Отступ снизу */ | |
} | |
.video-section{ | |
width: 100%; /* Полная ширина на мобильных */ | |
} | |
#video-controls { | |
bottom: 0; /* Прижимаем к низу */ | |
} | |
} | |
@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"> | |
<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> | |
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script> | |
</head> | |
<body> | |
<h1>Комната: {{ token }}</h1> | |
<div id="users"></div> | |
<div class="main-container"> | |
<div class="video-section"> | |
<div id="shared-video-container"> | |
{% if video_url %} | |
<iframe id="shared-video" src="{{ video_url }}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe> | |
<div id="video-controls"> | |
<button id="play-pause" data-state="paused"><i class="material-icons">play_arrow</i></button> | |
<button id="seek-backward"><i class="material-icons">replay_10</i></button> | |
<button id="seek-forward"><i class="material-icons">forward_10</i></button> | |
</div> | |
{% endif %} | |
</div> | |
{% if is_admin %} | |
<div class="admin-video-controls"> | |
<input type="text" id="video-url-input" placeholder="URL видео (YouTube, Rutube, VK)"> | |
<button id="set-video-url">Установить</button> | |
</div> | |
{% endif %} | |
<button class="leave-button" onclick="leaveRoom()">Покинуть комнату</button> | |
</div> | |
<div class="video-grid-wrapper"> | |
<div class="video-grid" id="video-grid"></div> | |
</div> | |
</div> | |
<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' }] | |
}; | |
let videoState = {{ video_state|tojson }}; | |
// ... (функции setupSharedVideo, playPauseVideo, seekVideo - без изменений) ... | |
function setupSharedVideo() { | |
const video = document.getElementById('shared-video'); | |
if (!video) return; | |
const playPauseButton = document.getElementById('play-pause'); | |
const seekBackwardButton = document.getElementById('seek-backward'); | |
const seekForwardButton = document.getElementById('seek-forward'); | |
function updatePlayPauseIcon() { | |
playPauseButton.innerHTML = `<i class="material-icons">${videoState.playing ? 'pause' : 'play_arrow'}</i>`; | |
} | |
playPauseButton.addEventListener('click', () => { | |
videoState.playing = !videoState.playing; | |
updatePlayPauseIcon(); | |
socket.emit('video_control', { token, action: 'play_pause', state: videoState }); | |
}); | |
seekBackwardButton.addEventListener('click', () => { | |
const newTime = Math.max(0, videoState.time - 10); | |
socket.emit('video_control', { token, action: 'seek', time: newTime, state: videoState }); | |
}); | |
seekForwardButton.addEventListener('click', () => { | |
const newTime = videoState.time + 10; | |
socket.emit('video_control', { token, action: 'seek', time: newTime, state: videoState }); | |
}); | |
updatePlayPauseIcon(); | |
if (videoState.time > 0) { | |
seekVideo(videoState.time); | |
} | |
if (isAdmin) { | |
const videoUrlInput = document.getElementById('video-url-input'); | |
const setVideoButton = document.getElementById('set-video-url'); | |
setVideoButton.addEventListener('click', () => { | |
const videoUrl = videoUrlInput.value; | |
if (videoUrl) { | |
socket.emit('set_video_url', { token, video_url: videoUrl }); | |
} | |
}); | |
} | |
} | |
function playPauseVideo(isPlaying) { | |
const video = document.getElementById('shared-video'); | |
if (!video) return; | |
if (isPlaying) { | |
if (typeof video.play === 'function') { | |
video.play().catch(e => console.error("Play error:", e)); | |
} else if (video.contentWindow && typeof video.contentWindow.postMessage === 'function') { | |
video.contentWindow.postMessage('{"event":"command","func":"playVideo","args":""}', '*'); | |
} | |
} else { | |
if (typeof video.pause === 'function') { | |
video.pause(); | |
} else if (video.contentWindow && typeof video.contentWindow.postMessage === 'function') { | |
video.contentWindow.postMessage('{"event":"command","func":"pauseVideo","args":""}', '*'); | |
} | |
} | |
} | |
function seekVideo(time) { | |
const video = document.getElementById('shared-video'); | |
if (!video) return; | |
if (typeof video.currentTime !== 'undefined') { // HTML5 video | |
video.currentTime = time; | |
} else if (video.contentWindow && typeof video.contentWindow.postMessage === 'function') { // iframe | |
// YouTube API (пример) | |
video.contentWindow.postMessage(`{"event":"command","func":"seekTo","args":[${time}, true]}`, '*'); | |
} | |
} | |
// Обработчики событий сокетов, связанных с общим видео (без изменений) | |
socket.on('video_url_updated', (data) => { | |
const videoContainer = $('#shared-video-container'); | |
videoContainer.empty(); | |
const newIframe = $('<iframe>', { | |
id: 'shared-video', | |
src: data.video_url, | |
frameborder: 0, | |
allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share', | |
allowfullscreen: true | |
}); | |
videoContainer.append(newIframe); | |
const videoControls = $('<div>', { id: 'video-controls' }); | |
videoControls.append($('<button>', { id: 'play-pause', 'data-state': 'paused' }).html('<i class="material-icons">play_arrow</i>')); | |
videoControls.append($('<button>', { id: 'seek-backward' }).html('<i class="material-icons">replay_10</i>')); | |
videoControls.append($('<button>', { id: 'seek-forward' }).html('<i class="material-icons">forward_10</i>')); | |
videoContainer.append(videoControls); | |
videoState = { playing: false, time: 0 }; | |
setupSharedVideo(); // Важно! | |
}); | |
socket.on('video_controlled', (data) => { | |
videoState = data.state; | |
if (data.action === 'play_pause') { | |
playPauseVideo(data.state.playing); | |
} else if (data.action === 'seek') { | |
seekVideo(data.state.time); | |
} | |
const playPauseButton = document.getElementById('play-pause'); | |
if (playPauseButton) { | |
playPauseButton.innerHTML = `<i class="material-icons">${data.state.playing ? 'pause' : 'play_arrow'}</i>`; | |
} | |
}); | |
// Функции для WebRTC (с исправлениями) | |
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); | |
} | |
} | |
function toggleVideo(user, button) { | |
const videoMuted = button.dataset.videoMuted === 'true'; | |
button.dataset.videoMuted = !videoMuted; | |
button.innerHTML = `<i class="material-icons">${!videoMuted ? 'videocam' : 'videocam_off'}</i>`; | |
if (user === username) { | |
localStream.getVideoTracks().forEach(track => track.enabled = !videoMuted); | |
} | |
} | |
function addVideoStream(stream, user, isLocal = false) { | |
console.log('Adding video stream for', user); | |
const existingVideo = document.querySelector(`video[data-user="${user}"]`); | |
if (existingVideo) { | |
console.log('Video already exists for', user, 'Replacing stream.'); | |
existingVideo.srcObject = stream; // Заменяем поток, если видео уже есть | |
return; | |
} | |
const videoContainer = document.createElement('div'); | |
videoContainer.classList.add('video-container'); | |
const video = document.createElement('video'); | |
video.srcObject = stream; | |
video.dataset.user = user; | |
video.setAttribute('playsinline', ''); // iOS | |
video.setAttribute('autoplay', ''); // Важно для автоматического воспроизведения | |
if (isLocal) { | |
video.muted = true; // Локальное видео мьютим | |
} | |
video.addEventListener('loadedmetadata', () => { | |
video.play().catch(e => console.error("Autoplay failed:", e)); | |
}); | |
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); | |
} | |
navigator.mediaDevices.getUserMedia({ video: true, audio: true }) | |
.then(stream => { | |
localStream = stream; | |
addVideoStream(stream, username, true); // Добавляем локальное видео *сразу* | |
socket.emit('join', { token, username }); | |
}) | |
.catch(err => console.error("Error accessing media devices:", err)); | |
function createPeerConnection(remoteUser) { | |
if (peers[remoteUser]) { | |
return peers[remoteUser].peerConnection; // Already exists | |
} | |
const peerConnection = new RTCPeerConnection(iceConfig); | |
peers[remoteUser] = { peerConnection: peerConnection, iceCandidates: [] }; | |
// Добавляем *все* треки из localStream в peerConnection | |
localStream.getTracks().forEach(track => { | |
peerConnection.addTrack(track, localStream); | |
}); | |
peerConnection.ontrack = event => { | |
console.log('Received remote stream from', remoteUser); | |
addVideoStream(event.streams[0], remoteUser); // Добавляем удаленное видео | |
}; | |
peerConnection.oniceconnectionstatechange = () => { | |
console.log(`ICE state change for ${remoteUser}: ${peerConnection.iceConnectionState}`); | |
if (peerConnection.iceConnectionState === 'failed' || peerConnection.iceConnectionState === 'disconnected') { | |
console.warn(`Connection with ${remoteUser} failed or disconnected.`); | |
if (peers[remoteUser]) { | |
peers[remoteUser].peerConnection.close(); | |
delete peers[remoteUser]; | |
const videoElement = document.querySelector(`video[data-user="${remoteUser}"]`); | |
if (videoElement) { | |
videoElement.parentElement.remove(); // Удаляем контейнер | |
} | |
} | |
} | |
}; | |
peerConnection.onicecandidate = event => { | |
if (event.candidate) { | |
socket.emit('signal', { | |
to: remoteUser, | |
from: username, | |
token: token, | |
signal: { type: 'candidate', candidate: event.candidate } | |
}); | |
} | |
}; | |
return peerConnection; | |
} | |
socket.on('signal', data => { | |
if (data.from === username) return; // Ignore signals from self | |
let peerEntry = peers[data.from]; | |
if (!peerEntry) { | |
// Если нет соединения, создаем новое | |
createPeerConnection(data.from); | |
peerEntry = peers[data.from]; // Get updated peerEntry | |
} | |
const peerConnection = peerEntry.peerConnection; | |
if (data.signal.type === 'offer') { | |
peerConnection.setRemoteDescription(new RTCSessionDescription(data.signal)) | |
.then(() => peerConnection.createAnswer()) | |
.then(answer => peerConnection.setLocalDescription(answer)) | |
.then(() => { | |
socket.emit('signal', { | |
to: data.from, | |
from: username, | |
token: token, | |
signal: peerConnection.localDescription | |
}); | |
// Add buffered candidates | |
while(peerEntry.iceCandidates.length > 0){ | |
const candidate = peerEntry.iceCandidates.shift(); | |
peerConnection.addIceCandidate(new RTCIceCandidate(candidate)) | |
.catch(err => console.error("Failed to add buffered ICE candidate:", err)); | |
} | |
}) | |
.catch(err => console.error("Error handling offer:", err)); | |
} else if (data.signal.type === 'answer') { | |
peerConnection.setRemoteDescription(new RTCSessionDescription(data.signal)) | |
.then(() => { | |
// Add buffered candidates | |
while(peerEntry.iceCandidates.length > 0){ | |
const candidate = peerEntry.iceCandidates.shift(); | |
peerConnection.addIceCandidate(new RTCIceCandidate(candidate)) | |
.catch(err => console.error("Failed to add buffered ICE candidate:", err)); | |
} | |
}) | |
.catch(err => console.error("Error handling answer:", err)); | |
} else if (data.signal.type === 'candidate') { | |
if (peerConnection.remoteDescription) { | |
peerConnection.addIceCandidate(new RTCIceCandidate(data.signal.candidate)) | |
.catch(err => console.error("Error adding ice candidate:", err)); | |
} else { | |
// Buffer the candidate | |
peerEntry.iceCandidates.push(data.signal.candidate); | |
console.log("Candidate buffered for", data.from); | |
} | |
} | |
}); | |
socket.on('user_joined', data => { | |
console.log('User joined:', data.username); | |
document.getElementById('users').innerText = 'Users: ' + data.users.join(', '); | |
// Create offer for the newly joined user | |
if (data.username !== username) { | |
const peerConnection = createPeerConnection(data.username); | |
peerConnection.createOffer() | |
.then(offer => peerConnection.setLocalDescription(offer)) | |
.then(() => { | |
socket.emit('signal', { | |
to: data.username, | |
from: username, | |
token: token, | |
signal: peerConnection.localDescription | |
}); | |
}) | |
.catch(err => console.error("Error creating offer:", err)); | |
} | |
}); | |
socket.on('init_users', data => { | |
console.log("Initial users:", data.users); | |
// Create peer connections for existing users. | |
data.users.forEach(user => { | |
if (user !== username) { | |
createPeerConnection(user); | |
} | |
}); | |
}); | |
socket.on('user_left', data => { | |
console.log('User left:', data.username); | |
document.getElementById('users').innerText = 'Users: ' + data.users.join(', '); | |
if (peers[data.username]) { | |
peers[data.username].peerConnection.close(); | |
delete peers[data.username]; | |
const videoElement = document.querySelector(`video[data-user="${data.username}"]`); | |
if(videoElement){ | |
videoElement.parentElement.remove(); // Remove the container | |
} | |
} | |
}); | |
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, username }); | |
if (localStream) { | |
localStream.getTracks().forEach(track => track.stop()); | |
} | |
for (let user in peers) { | |
peers[user].peerConnection.close(); | |
} | |
window.location.href = '/dashboard'; | |
} | |
$(document).ready(() => { | |
setupSharedVideo(); // Важно! Инициализация после загрузки DOM | |
}); | |
</script> | |
</body> | |
</html> | |
''', token=token, session=session, is_admin=is_admin, video_url=video_url, video_state=video_state) | |
# WebSocket events | |
def handle_join(data): | |
token = data['token'] | |
username = data['username'] | |
print(f"User {username} joining room {token}") | |
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('initial_video_state', {'state': rooms[token]['video_state']}, to=request.sid) | |
# Broadcast to everyone (including the new user) | |
emit('user_joined', {'username': username, 'users': rooms[token]['users']}, room=token) | |
# Send the list of users directly to the new user | |
emit('init_users', {'users': rooms[token]['users']}, to=request.sid) | |
else: | |
emit('error_message', {'message': 'Room is full or does not exist'}, to=request.sid) | |
def handle_leave(data): | |
token = data['token'] | |
username = data['username'] | |
print(f"User {username} leaving room {token}") | |
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): | |
# Send the signal to the specified recipient | |
emit('signal', data, room=data['token'], include_self=False) | |
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) | |
def handle_set_video_url(data): | |
token = data['token'] | |
video_url = data['video_url'] | |
print(f"Setting video URL for room {token} to {video_url}") | |
if token in rooms and rooms[token]['admin'] == session.get('username'): | |
# Sanitize the URL (basic example - prevent script injection) | |
if '<script' in video_url.lower(): | |
print("Invalid video URL - contains script tags") | |
return # Reject the URL | |
# YouTube, Rutube, or VK Video URL. | |
video_url = convert_to_embed_url(video_url) | |
if not video_url: | |
print("Invalid video URL - not from a supported platform") | |
return | |
rooms[token]['video_url'] = video_url | |
rooms[token]['video_state'] = {'playing': False, 'time': 0} | |
save_json(ROOMS_DB, rooms) | |
emit('video_url_updated', {'video_url': video_url}, room=token) | |
def handle_video_control(data): | |
token = data['token'] | |
action = data['action'] | |
if token not in rooms: | |
return | |
if action == 'play_pause': | |
rooms[token]['video_state']['playing'] = data['state']['playing'] | |
elif action == 'seek': | |
rooms[token]['video_state']['time'] = data['time'] | |
save_json(ROOMS_DB, rooms) | |
emit('video_controlled', {'action': action, 'state': rooms[token]['video_state']}, room=token) | |
def convert_to_embed_url(url): | |
"""Converts a YouTube, Rutube, or VK Video URL to an embeddable URL.""" | |
try: | |
if "youtube.com" in url or "youtu.be" in url: | |
if "watch?v=" in url: | |
video_id = url.split("watch?v=")[1].split("&")[0] | |
elif "youtu.be" in url: | |
video_id = url.split("youtu.be/")[1].split("?")[0] | |
else: | |
return None # Invalid YouTube URL | |
return f"https://www.youtube.com/embed/{video_id}?autoplay=1&controls=0" | |
elif "rutube.ru" in url: | |
if "/video/" in url: | |
video_id = url.split("/video/")[1].split("/")[0] | |
elif "/play/embed/" in url: # Already embedded | |
return url + "?autoplay=1" # Keep autoplay | |
else: | |
return None | |
return f"https://rutube.ru/play/embed/{video_id}?autoplay=1" | |
elif "vk.com" in url: | |
if "video" in url and "z=" in url: | |
video_id = url.split("z=video")[1].split("%")[0] | |
return f"https://vk.com/video_ext.php?oid={video_id}&hd=2&autoplay=1" #hd=2 is optional | |
elif "video_ext.php" in url: # Already embedded | |
return url.replace("autoplay=0", "autoplay=1") # Ensure autoplay | |
else: | |
return None | |
return None # Unsupported platform | |
except Exception as e: | |
print(f"Error converting URL: {e}") | |
return None | |
if __name__ == '__main__': | |
socketio.run(app, host='0.0.0.0', port=7860, debug=True, allow_unsafe_werkzeug=True) |