Update environment configuration and enhance control features in WebRTC

- Changed VITE_API_URL and VITE_WEBRTC_URL in .env to point to local IP addresses.
- Added react-hot-toast for user notifications in the application.
- Integrated toast notifications for control acquisition in SessionPage.
- Enhanced PixelStreamingWrapper and SessionUsersPanel to manage control states for participants.
- Implemented grant and revoke control functionalities in the WebRTC service, allowing dynamic control management among users.
- Updated various components to reflect control states and improve user experience during sessions.
This commit is contained in:
2025-12-01 20:23:17 +05:00
parent 48f5833046
commit 775ba52cd0
20 changed files with 979 additions and 161 deletions
+487 -48
View File
@@ -61,10 +61,13 @@ interface User {
socketId: string;
isAudioEnabled?: boolean;
isVideoEnabled?: boolean;
hasControl?: boolean;
}
const rooms = new Map<string, Room>();
const users = new Map<string, User>();
// Таймеры для автоматического завершения сессий (roomId -> NodeJS.Timeout)
const sessionEndTimers = new Map<string, NodeJS.Timeout>();
// Вспомогательные функции
function findUserBySocketId(socketId: string): User | undefined {
@@ -81,6 +84,93 @@ function findSocketIdByUserId(userId: string): string | undefined {
return user?.socketId;
}
/**
* Отменить таймер автоматического завершения сессии для комнаты
*/
function cancelSessionEndTimer(roomId: string) {
const timer = sessionEndTimers.get(roomId);
if (timer) {
clearTimeout(timer);
sessionEndTimers.delete(roomId);
console.log(
`[Session Auto-End] Cancelled auto-end timer for room ${roomId}`
);
}
}
/**
* Запустить таймер автоматического завершения сессии через 3 минуты
*/
async function startSessionEndTimer(roomId: string) {
// Отменяем существующий таймер, если есть
cancelSessionEndTimer(roomId);
// Проверяем статус сессии - если уже завершена, не запускаем таймер
try {
const session = await serverSessionService.findById(roomId);
if (session && (session.status === "ended" || session.status === "ending")) {
console.log(
`[Session Auto-End] Session ${roomId} is already ended or ending, skipping auto-end timer`
);
return;
}
} catch (error) {
console.error(
`[Session Auto-End] Error checking session status for ${roomId}:`,
error instanceof Error ? error.message : error
);
// Продолжаем выполнение, если не удалось проверить статус
}
const TIMEOUT_MS = 3 * 60 * 1000; // 3 минуты
console.log(
`[Session Auto-End] Starting auto-end timer for room ${roomId} (will end in 3 minutes if no participants join)`
);
const timer = setTimeout(async () => {
try {
// Проверяем, что комната все еще пустая
const room = rooms.get(roomId);
if (room && room.participants.size === 0) {
console.log(
`[Session Auto-End] Room ${roomId} has been empty for 3 minutes, ending session`
);
// Завершаем сессию
const session = await serverSessionService.findById(roomId);
if (session && (session.status === "started" || session.status === "starting")) {
await serverSessionService.end(roomId);
console.log(
`[Session Auto-End] Session ${roomId} has been ended automatically`
);
} else {
console.log(
`[Session Auto-End] Session ${roomId} is already ended or ending, skipping`
);
}
// Удаляем комнату и таймер
rooms.delete(roomId);
sessionEndTimers.delete(roomId);
} else {
console.log(
`[Session Auto-End] Room ${roomId} has participants now, cancelling auto-end`
);
sessionEndTimers.delete(roomId);
}
} catch (error) {
console.error(
`[Session Auto-End] Error ending session ${roomId}:`,
error instanceof Error ? error.message : error
);
sessionEndTimers.delete(roomId);
}
}, TIMEOUT_MS);
sessionEndTimers.set(roomId, timer);
}
io.on("connection", (socket) => {
console.log(`[WebRTC] User connected: ${socket.id}`);
@@ -92,9 +182,32 @@ io.on("connection", (socket) => {
`[WebRTC] User ${userId} (socket: ${socket.id}) joining room ${roomId}, audio: ${isAudioEnabled}, video: ${isVideoEnabled}`
);
// Покинуть предыдущую комнату если была
// Проверяем существующего пользователя
const existingUser = users.get(userId);
if (existingUser?.roomId) {
// Если пользователь уже в этой комнате с тем же socket.id - это дубликат запроса
if (
existingUser &&
existingUser.roomId === roomId &&
existingUser.socketId === socket.id
) {
console.log(
`[WebRTC] User ${userId} is already in room ${roomId} with same socket, ignoring duplicate join`
);
// Но все равно отправляем состояние управления на случай если оно не было получено
socket.emit("control-toggle", {
userId,
hasControl: existingUser.hasControl || false,
});
return;
}
// Покинуть предыдущую комнату если была
if (
existingUser &&
existingUser.roomId &&
existingUser.roomId !== roomId
) {
console.log(
`[WebRTC] User ${userId} leaving previous room ${existingUser.roomId}`
);
@@ -103,6 +216,19 @@ io.on("connection", (socket) => {
if (prevRoom) {
prevRoom.participants.delete(userId);
socket.to(existingUser.roomId).emit("user-left", userId);
// Если предыдущая комната стала пустой, запускаем таймер завершения сессии
if (prevRoom.participants.size === 0) {
console.log(
`[WebRTC] Previous room ${existingUser.roomId} is now empty, starting auto-end timer`
);
startSessionEndTimer(existingUser.roomId).catch((error) => {
console.error(
`[WebRTC] Error starting auto-end timer for room ${existingUser.roomId}:`,
error
);
});
}
}
}
@@ -119,23 +245,88 @@ io.on("connection", (socket) => {
}
const room = rooms.get(roomId)!;
room.participants.add(userId);
// Сохранить пользователя с состоянием аудио/видео
users.set(userId, {
// Если пользователь уже в участниках комнаты (переподключение), удаляем его из старого socket
if (room.participants.has(userId)) {
console.log(
`[WebRTC] User ${userId} is reconnecting to room ${roomId}, updating socket`
);
// Не возвращаемся - продолжаем обработку для обновления socketId и отправки состояния
} else {
room.participants.add(userId);
// Отменяем таймер завершения сессии, так как появился участник
cancelSessionEndTimer(roomId);
}
// Проверяем, является ли пользователь владельцем сессии
let hasControl = false;
try {
const session = await serverSessionService.findById(roomId);
if (session) {
// Владелец - это userId (для авторизованных) или guestId (для гостей)
const isOwner = Boolean(
(session.userId && session.userId === userId) ||
(session.guestId && session.guestId === userId)
);
if (isOwner) {
// Проверяем, есть ли в комнате участники с управлением (кроме самого организатора)
let hasControllerInRoom = false;
for (const [uid, user] of users.entries()) {
if (user.roomId === roomId && user.hasControl && uid !== userId) {
hasControllerInRoom = true;
break;
}
}
// Если нет участников с управлением, передаем управление организатору
if (!hasControllerInRoom) {
hasControl = true;
console.log(
`[WebRTC] Owner ${userId} joining/reconnecting, no controller in room, granting control`
);
} else {
// Если есть участник с управлением, сохраняем текущее состояние организатора
// (если у организатора было управление, оно остается, если не было - остается без управления)
hasControl = existingUser?.hasControl || false;
console.log(
`[WebRTC] Owner ${userId} joining/reconnecting, controller exists in room, keeping current state (hasControl=${hasControl})`
);
}
} else {
// Для не-организаторов сохраняем существующее состояние управления
hasControl = existingUser?.hasControl || false;
}
}
} catch (error) {
console.error(`[WebRTC] Error checking session owner:`, error);
}
// Сохранить/обновить пользователя с состоянием аудио/видео и управления
const userData: User = {
id: userId,
roomId,
socketId: socket.id,
isAudioEnabled: isAudioEnabled !== false,
isVideoEnabled: isVideoEnabled !== false,
});
hasControl: hasControl || false,
};
// Если пользователь уже существует, обновляем его данные (особенно socketId при переподключении)
if (existingUser) {
Object.assign(existingUser, userData);
} else {
users.set(userId, userData);
}
console.log(
`[WebRTC] Room ${roomId} now has participants:`,
Array.from(room.participants)
);
console.log(
`[WebRTC] User ${userId} media state: audio=${isAudioEnabled !== false}, video=${isVideoEnabled !== false}`
`[WebRTC] User ${userId} media state: audio=${
isAudioEnabled !== false
}, video=${isVideoEnabled !== false}`
);
// Уведомить других участников
@@ -162,12 +353,12 @@ io.on("connection", (socket) => {
);
socket.emit("room-participants", participants);
// Отправить состояние аудио/видео существующих участников новому пользователю
// Отправить состояние аудио/видео и управления существующих участников новому пользователю
participants.forEach((participantId) => {
const participant = users.get(participantId);
if (participant) {
console.log(
`[WebRTC] Sending ${participantId} media state to ${userId}: audio=${participant.isAudioEnabled}, video=${participant.isVideoEnabled}`
`[WebRTC] Sending ${participantId} media state to ${userId}: audio=${participant.isAudioEnabled}, video=${participant.isVideoEnabled}, hasControl=${participant.hasControl}`
);
socket.emit("audio-toggle", {
userId: participantId,
@@ -177,8 +368,26 @@ io.on("connection", (socket) => {
userId: participantId,
isEnabled: participant.isVideoEnabled !== false,
});
socket.emit("control-toggle", {
userId: participantId,
hasControl: participant.hasControl || false,
});
}
});
// Отправить состояние управления нового пользователя всем в комнате
socket.to(roomId).emit("control-toggle", {
userId,
hasControl,
});
// Отправить состояние управления самому новому пользователю
socket.emit("control-toggle", {
userId,
hasControl,
});
console.log(`[WebRTC] User ${userId} hasControl: ${hasControl}`);
}
);
@@ -192,10 +401,17 @@ io.on("connection", (socket) => {
room.participants.delete(userId);
socket.to(roomId).emit("user-left", userId);
// Удалить пустую комнату
// Если комната стала пустой, запускаем таймер завершения сессии
if (room.participants.size === 0) {
rooms.delete(roomId);
console.log(`[WebRTC] Deleted empty room ${roomId}`);
console.log(
`[WebRTC] Room ${roomId} is now empty, starting auto-end timer`
);
startSessionEndTimer(roomId).catch((error) => {
console.error(
`[WebRTC] Error starting auto-end timer for room ${roomId}:`,
error
);
});
}
}
@@ -250,14 +466,14 @@ io.on("connection", (socket) => {
console.log(
`[WebRTC] Audio toggle from ${userId} in room ${roomId}: ${isEnabled}`
);
// Обновляем сохраненное состояние пользователя
const user = users.get(userId);
if (user) {
user.isAudioEnabled = isEnabled;
console.log(`[WebRTC] Updated ${userId} audio state to ${isEnabled}`);
}
// Отправляем всем в комнате (кроме отправителя)
socket.to(roomId).emit("audio-toggle", { userId, isEnabled });
});
@@ -266,14 +482,14 @@ io.on("connection", (socket) => {
console.log(
`[WebRTC] Video toggle from ${userId} in room ${roomId}: ${isEnabled}`
);
// Обновляем сохраненное состояние пользователя
const user = users.get(userId);
if (user) {
user.isVideoEnabled = isEnabled;
console.log(`[WebRTC] Updated ${userId} video state to ${isEnabled}`);
}
// Отправляем всем в комнате (кроме отправителя)
socket.to(roomId).emit("video-toggle", { userId, isEnabled });
});
@@ -289,15 +505,17 @@ io.on("connection", (socket) => {
console.log(
`[WebRTC] Mute participant request: ${targetUserId} in room ${roomId}`
);
// Получаем информацию о пользователе, который отправил команду
const requestingUser = findUserBySocketId(socket.id);
if (!requestingUser) {
console.warn(`[WebRTC] Unauthorized mute request from unknown socket ${socket.id}`);
console.warn(
`[WebRTC] Unauthorized mute request from unknown socket ${socket.id}`
);
socket.emit("error", { message: "Unauthorized: user not found" });
return;
}
// Проверяем, что пользователь находится в той же комнате
if (requestingUser.roomId !== roomId) {
console.warn(
@@ -306,7 +524,7 @@ io.on("connection", (socket) => {
socket.emit("error", { message: "Unauthorized: not in the same room" });
return;
}
// Проверяем, что пользователь является организатором сессии
try {
const session = await serverSessionService.findById(roomId);
@@ -315,44 +533,51 @@ io.on("connection", (socket) => {
socket.emit("error", { message: "Session not found" });
return;
}
// Проверяем, что запрашивающий пользователь - организатор
// Организатор - это userId (для авторизованных) или guestId (для гостей)
const isOrganizer =
const isOrganizer =
(session.userId && session.userId === requestingUser.id) ||
(session.guestId && session.guestId === requestingUser.id);
if (!isOrganizer) {
console.warn(
`[WebRTC] User ${requestingUser.id} is not the organizer of session ${roomId}`
);
socket.emit("error", { message: "Unauthorized: only organizer can mute participants" });
socket.emit("error", {
message: "Unauthorized: only organizer can mute participants",
});
return;
}
console.log(`[WebRTC] User ${requestingUser.id} is authorized as organizer`);
console.log(
`[WebRTC] User ${requestingUser.id} is authorized as organizer`
);
} catch (error) {
console.error(`[WebRTC] Error checking session organizer:`, error);
socket.emit("error", { message: "Failed to verify permissions" });
return;
}
// Обновляем состояние участника
const targetUser = users.get(targetUserId);
if (targetUser) {
targetUser.isAudioEnabled = false;
console.log(`[WebRTC] Updated ${targetUserId} audio state to false`);
}
// Отправляем команду конкретному участнику
const targetSocketId = findSocketIdByUserId(targetUserId);
if (targetSocketId) {
io.to(targetSocketId).emit("force-mute-audio");
console.log(`[WebRTC] Sent force-mute-audio to ${targetUserId}`);
}
// Уведомляем всех в комнате об изменении состояния
io.to(roomId).emit("audio-toggle", { userId: targetUserId, isEnabled: false });
io.to(roomId).emit("audio-toggle", {
userId: targetUserId,
isEnabled: false,
});
});
// Обработка команды выключения камеры участника
@@ -360,15 +585,17 @@ io.on("connection", (socket) => {
console.log(
`[WebRTC] Disable video request: ${targetUserId} in room ${roomId}`
);
// Получаем информацию о пользователе, который отправил команду
const requestingUser = findUserBySocketId(socket.id);
if (!requestingUser) {
console.warn(`[WebRTC] Unauthorized disable video request from unknown socket ${socket.id}`);
console.warn(
`[WebRTC] Unauthorized disable video request from unknown socket ${socket.id}`
);
socket.emit("error", { message: "Unauthorized: user not found" });
return;
}
// Проверяем, что пользователь находится в той же комнате
if (requestingUser.roomId !== roomId) {
console.warn(
@@ -377,7 +604,7 @@ io.on("connection", (socket) => {
socket.emit("error", { message: "Unauthorized: not in the same room" });
return;
}
// Проверяем, что пользователь является организатором сессии
try {
const session = await serverSessionService.findById(roomId);
@@ -386,44 +613,251 @@ io.on("connection", (socket) => {
socket.emit("error", { message: "Session not found" });
return;
}
// Проверяем, что запрашивающий пользователь - организатор
// Организатор - это userId (для авторизованных) или guestId (для гостей)
const isOrganizer =
const isOrganizer =
(session.userId && session.userId === requestingUser.id) ||
(session.guestId && session.guestId === requestingUser.id);
if (!isOrganizer) {
console.warn(
`[WebRTC] User ${requestingUser.id} is not the organizer of session ${roomId}`
);
socket.emit("error", { message: "Unauthorized: only organizer can disable video" });
socket.emit("error", {
message: "Unauthorized: only organizer can disable video",
});
return;
}
console.log(`[WebRTC] User ${requestingUser.id} is authorized as organizer`);
console.log(
`[WebRTC] User ${requestingUser.id} is authorized as organizer`
);
} catch (error) {
console.error(`[WebRTC] Error checking session organizer:`, error);
socket.emit("error", { message: "Failed to verify permissions" });
return;
}
// Обновляем состояние участника
const targetUser = users.get(targetUserId);
if (targetUser) {
targetUser.isVideoEnabled = false;
console.log(`[WebRTC] Updated ${targetUserId} video state to false`);
}
// Отправляем команду конкретному участнику
const targetSocketId = findSocketIdByUserId(targetUserId);
if (targetSocketId) {
io.to(targetSocketId).emit("force-disable-video");
console.log(`[WebRTC] Sent force-disable-video to ${targetUserId}`);
}
// Уведомляем всех в комнате об изменении состояния
io.to(roomId).emit("video-toggle", { userId: targetUserId, isEnabled: false });
io.to(roomId).emit("video-toggle", {
userId: targetUserId,
isEnabled: false,
});
});
// Обработка передачи управления PixelStreaming
socket.on("grant-control", async ({ roomId, targetUserId }) => {
console.log(
`[WebRTC] Grant control request: ${targetUserId} in room ${roomId}`
);
// Получаем информацию о пользователе, который отправил команду
const requestingUser = findUserBySocketId(socket.id);
if (!requestingUser) {
console.warn(
`[WebRTC] Unauthorized grant control request from unknown socket ${socket.id}`
);
socket.emit("error", { message: "Unauthorized: user not found" });
return;
}
// Проверяем, что пользователь находится в той же комнате
if (requestingUser.roomId !== roomId) {
console.warn(
`[WebRTC] User ${requestingUser.id} tried to grant control in room ${roomId}, but is in room ${requestingUser.roomId}`
);
socket.emit("error", { message: "Unauthorized: not in the same room" });
return;
}
// Проверяем, что пользователь является владельцем сессии
try {
const session = await serverSessionService.findById(roomId);
if (!session) {
console.warn(`[WebRTC] Session ${roomId} not found`);
socket.emit("error", { message: "Session not found" });
return;
}
// Проверяем, что запрашивающий пользователь - владелец
// Владелец - это userId (для авторизованных) или guestId (для гостей)
const isOwner =
(session.userId && session.userId === requestingUser.id) ||
(session.guestId && session.guestId === requestingUser.id);
if (!isOwner) {
console.warn(
`[WebRTC] User ${requestingUser.id} is not the owner of session ${roomId}`
);
socket.emit("error", {
message: "Unauthorized: only owner can grant control",
});
return;
}
console.log(`[WebRTC] User ${requestingUser.id} is authorized as owner`);
} catch (error) {
console.error(`[WebRTC] Error checking session owner:`, error);
socket.emit("error", { message: "Failed to verify permissions" });
return;
}
// Проверяем, что целевой пользователь существует и находится в комнате
const targetUser = users.get(targetUserId);
if (!targetUser || targetUser.roomId !== roomId) {
console.warn(
`[WebRTC] Target user ${targetUserId} not found or not in room ${roomId}`
);
socket.emit("error", { message: "Target user not found" });
return;
}
// Находим текущего пользователя с управлением в этой комнате
let currentController: User | undefined;
for (const [userId, user] of users.entries()) {
if (user.roomId === roomId && user.hasControl) {
currentController = user;
break;
}
}
// Если есть текущий контроллер и это не целевой пользователь, отзываем управление
if (currentController && currentController.id !== targetUserId) {
currentController.hasControl = false;
const currentControllerSocketId = findSocketIdByUserId(
currentController.id
);
if (currentControllerSocketId) {
io.to(currentControllerSocketId).emit("control-revoked");
console.log(`[WebRTC] Revoked control from ${currentController.id}`);
}
// Уведомляем всех в комнате об отзыве управления
io.to(roomId).emit("control-toggle", {
userId: currentController.id,
hasControl: false,
});
}
// Предоставляем управление целевому пользователю
targetUser.hasControl = true;
const targetSocketId = findSocketIdByUserId(targetUserId);
if (targetSocketId) {
io.to(targetSocketId).emit("control-granted");
console.log(`[WebRTC] Granted control to ${targetUserId}`);
}
// Уведомляем всех в комнате о предоставлении управления
io.to(roomId).emit("control-toggle", {
userId: targetUserId,
hasControl: true,
});
});
// Обработка возврата управления владельцем
socket.on("revoke-control", async ({ roomId }) => {
console.log(`[WebRTC] Revoke control request in room ${roomId}`);
// Получаем информацию о пользователе, который отправил команду
const requestingUser = findUserBySocketId(socket.id);
if (!requestingUser) {
console.warn(
`[WebRTC] Unauthorized revoke control request from unknown socket ${socket.id}`
);
socket.emit("error", { message: "Unauthorized: user not found" });
return;
}
// Проверяем, что пользователь находится в той же комнате
if (requestingUser.roomId !== roomId) {
console.warn(
`[WebRTC] User ${requestingUser.id} tried to revoke control in room ${roomId}, but is in room ${requestingUser.roomId}`
);
socket.emit("error", { message: "Unauthorized: not in the same room" });
return;
}
// Проверяем, что пользователь является владельцем сессии
try {
const session = await serverSessionService.findById(roomId);
if (!session) {
console.warn(`[WebRTC] Session ${roomId} not found`);
socket.emit("error", { message: "Session not found" });
return;
}
// Проверяем, что запрашивающий пользователь - владелец
const isOwner =
(session.userId && session.userId === requestingUser.id) ||
(session.guestId && session.guestId === requestingUser.id);
if (!isOwner) {
console.warn(
`[WebRTC] User ${requestingUser.id} is not the owner of session ${roomId}`
);
socket.emit("error", {
message: "Unauthorized: only owner can revoke control",
});
return;
}
console.log(`[WebRTC] User ${requestingUser.id} is authorized as owner`);
} catch (error) {
console.error(`[WebRTC] Error checking session owner:`, error);
socket.emit("error", { message: "Failed to verify permissions" });
return;
}
// Находим текущего пользователя с управлением в этой комнате
let currentController: User | undefined;
for (const [userId, user] of users.entries()) {
if (user.roomId === roomId && user.hasControl) {
currentController = user;
break;
}
}
// Если есть текущий контроллер, отзываем управление
if (currentController) {
currentController.hasControl = false;
const currentControllerSocketId = findSocketIdByUserId(
currentController.id
);
if (currentControllerSocketId) {
io.to(currentControllerSocketId).emit("control-revoked");
console.log(`[WebRTC] Revoked control from ${currentController.id}`);
}
// Уведомляем всех в комнате об отзыве управления
io.to(roomId).emit("control-toggle", {
userId: currentController.id,
hasControl: false,
});
}
// Возвращаем управление владельцу
requestingUser.hasControl = true;
socket.emit("control-granted");
console.log(`[WebRTC] Returned control to owner ${requestingUser.id}`);
// Уведомляем всех в комнате о возврате управления владельцу
io.to(roomId).emit("control-toggle", {
userId: requestingUser.id,
hasControl: true,
});
});
// Обработка сообщений чата
@@ -494,7 +928,7 @@ io.on("connection", (socket) => {
const messageData = {
sessionId: roomId,
userId: userId || null, // userId для авторизованных пользователей
guestId: userId ? null : (guestId || null), // guestId только если нет userId
guestId: userId ? null : guestId || null, // guestId только если нет userId
senderName: finalSenderName, // Имя отправителя
content,
type: "text" as const,
@@ -561,12 +995,17 @@ io.on("connection", (socket) => {
.to(disconnectedUser.roomId)
.emit("user-left", disconnectedUser.id);
// Удалить пустую комнату
// Если комната стала пустой, запускаем таймер завершения сессии
if (room.participants.size === 0) {
rooms.delete(disconnectedUser.roomId);
console.log(
`[WebRTC] Deleted empty room ${disconnectedUser.roomId}`
`[WebRTC] Room ${disconnectedUser.roomId} is now empty, starting auto-end timer`
);
startSessionEndTimer(disconnectedUser.roomId).catch((error) => {
console.error(
`[WebRTC] Error starting auto-end timer for room ${disconnectedUser.roomId}:`,
error
);
});
}
}
}