Videospace3 / app.py
Aleksmorshen's picture
Update app.py
eb179ca verified
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 - без изменений) ...
@app.route('/', methods=['GET', 'POST'])
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>''')
# Панель управления
@app.route('/dashboard', methods=['GET', 'POST'])
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)
# Выход из системы
@app.route('/logout', methods=['POST'])
def logout():
session.pop('username', None)
return redirect(url_for('index'))
@app.route('/room/<token>')
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
@socketio.on('join')
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)
@socketio.on('leave')
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)
@socketio.on('signal')
def handle_signal(data):
# Send the signal to the specified recipient
emit('signal', data, room=data['token'], include_self=False)
@socketio.on('admin_mute')
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)
@socketio.on('set_video_url')
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)
@socketio.on('video_control')
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)