diff --git a/client/.env b/client/.env index 5e374be..9a1efaa 100644 --- a/client/.env +++ b/client/.env @@ -1,4 +1,4 @@ -# VITE_API_URL=http://localhost:3000 -# VITE_WEBRTC_URL=http://localhost:3001 +# VITE_API_URL=http://192.168.1.136:3000 +# VITE_WEBRTC_URL=http://192.168.1.136:3001 VITE_API_URL=https://stream.graff.estate/api VITE_WEBRTC_URL=https://stream.graff.estate \ No newline at end of file diff --git a/client/bun.lock b/client/bun.lock index cb64456..8d2db1a 100644 --- a/client/bun.lock +++ b/client/bun.lock @@ -12,6 +12,7 @@ "motion": "^12.23.24", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-hot-toast": "^2.6.0", "react-qr-code": "^2.0.18", "react-router": "^7.9.3", "socket.io-client": "^4.8.1", @@ -401,6 +402,8 @@ "globals": ["globals@16.4.0", "", {}, "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw=="], + "goober": ["goober@2.1.18", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw=="], + "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -545,6 +548,8 @@ "react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="], + "react-hot-toast": ["react-hot-toast@2.6.0", "", { "dependencies": { "csstype": "^3.1.3", "goober": "^2.1.16" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg=="], + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "react-qr-code": ["react-qr-code@2.0.18", "", { "dependencies": { "prop-types": "^15.8.1", "qr.js": "0.0.0" }, "peerDependencies": { "react": "*" } }, "sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg=="], diff --git a/client/package.json b/client/package.json index 2d7e007..5690bca 100644 --- a/client/package.json +++ b/client/package.json @@ -18,6 +18,7 @@ "motion": "^12.23.24", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-hot-toast": "^2.6.0", "react-qr-code": "^2.0.18", "react-router": "^7.9.3", "socket.io-client": "^4.8.1", diff --git a/client/public/img/UserCameraAvatar.png b/client/public/img/UserCameraAvatar.png index 9a2dabc..5378d82 100644 Binary files a/client/public/img/UserCameraAvatar.png and b/client/public/img/UserCameraAvatar.png differ diff --git a/client/public/img/UserCameraAvatar2.png b/client/public/img/UserCameraAvatar2.png new file mode 100644 index 0000000..9a2dabc Binary files /dev/null and b/client/public/img/UserCameraAvatar2.png differ diff --git a/client/src/components/PixelStreamingWrapper.tsx b/client/src/components/PixelStreamingWrapper.tsx index 03893bf..d30474f 100644 --- a/client/src/components/PixelStreamingWrapper.tsx +++ b/client/src/components/PixelStreamingWrapper.tsx @@ -11,11 +11,13 @@ import type { AllSettings } from "@epicgames-ps/lib-pixelstreamingfrontend-ue5.7 export interface PixelStreamingWrapperProps { initialSettings?: Partial; onVideoInitialized?: () => void; + hasControl?: boolean; } export const PixelStreamingWrapper = ({ initialSettings, onVideoInitialized, + hasControl = true, }: PixelStreamingWrapperProps) => { // A reference to parent div element that the Pixel Streaming library attaches into: const videoParent = useRef(null); @@ -110,6 +112,26 @@ export const PixelStreamingWrapper = ({ }; }, []); + // Обновляем состояние ввода при изменении hasControl + useEffect(() => { + if (!pixelStreaming || !videoParent.current) return; + + const videoElement = videoParent.current.querySelector("video"); + if (videoElement) { + if (hasControl) { + videoElement.style.pointerEvents = "auto"; + videoElement.style.userSelect = "auto"; + } else { + videoElement.style.pointerEvents = "none"; + videoElement.style.userSelect = "none"; + } + } + + if (videoParent.current) { + videoParent.current.style.pointerEvents = hasControl ? "auto" : "none"; + } + }, [hasControl, pixelStreaming]); + return (
{ pixelStreaming?.play(); diff --git a/client/src/components/SessionUsersPanel.tsx b/client/src/components/SessionUsersPanel.tsx index 197a7a7..e8fd987 100644 --- a/client/src/components/SessionUsersPanel.tsx +++ b/client/src/components/SessionUsersPanel.tsx @@ -34,6 +34,9 @@ function SessionUsersPanel({ updateSpeakingState, muteParticipant, disableParticipantVideo, + grantControl, + revokeControl, + hasControl: localHasControl, currentUserId, } = useWebRTC(roomId, autoJoin); @@ -212,13 +215,15 @@ function SessionUsersPanel({ name="Вы" isMuted={isLocalAudioMuted} isVideoOff={isLocalVideoMuted} - hasControl={false} + hasControl={localHasControl} isAdmin={isLocalUserOrganizer} isLocal={true} mediaStream={localStream} onSpeakingChange={handleSpeakingChange} isLocalUserOrganizer={isLocalUserOrganizer} participantId={currentUserId} + onGrantControl={grantControl} + onRevokeControl={revokeControl} className={clsx( mode === "full" && (activeCamerasCount <= 2 @@ -249,7 +254,7 @@ function SessionUsersPanel({ isMuted={participant.isMuted || false} isVideoOff={participant.isVideoOff || false} isSpeaking={participant.isSpeaking} - hasControl={false} + hasControl={participant.hasControl || false} isAdmin={isParticipantOrganizer(participant.id) || undefined} mediaStream={participant.stream} hasLocalMediaPermission={hasLocalStream} @@ -257,6 +262,8 @@ function SessionUsersPanel({ participantId={participant.id} onMuteParticipant={muteParticipant} onDisableParticipantVideo={disableParticipantVideo} + onGrantControl={grantControl} + onRevokeControl={revokeControl} /> ))} @@ -267,6 +274,14 @@ function SessionUsersPanel({ isAudioMuted={isLocalAudioMuted} isVideoMuted={isLocalVideoMuted} hasLocalStream={hasLocalStream} + hasControl={localHasControl} + onToggleControl={() => { + // Организатор всегда может вернуть управление себе + if (isLocalUserOrganizer) { + revokeControl(); + } + }} + isOrganizer={isLocalUserOrganizer} /> ); diff --git a/client/src/components/popups/ParticipantsPopup.tsx b/client/src/components/popups/ParticipantsPopup.tsx index 727cf1e..3ce8254 100644 --- a/client/src/components/popups/ParticipantsPopup.tsx +++ b/client/src/components/popups/ParticipantsPopup.tsx @@ -28,6 +28,9 @@ export default function ParticipantsPopup({ session }: ParticipantsPopupProps) { isVideoMuted, muteParticipant, disableParticipantVideo, + grantControl, + revokeControl, + hasControl: localHasControl, } = useWebRTC(); const { data: user } = useMe(); const headerRef = useRef(null); @@ -103,6 +106,9 @@ export default function ParticipantsPopup({ session }: ParticipantsPopupProps) { session={session} muteParticipant={muteParticipant} disableParticipantVideo={disableParticipantVideo} + grantControl={grantControl} + revokeControl={revokeControl} + localHasControl={localHasControl} />
@@ -141,6 +147,9 @@ function ParticipantItem({ session, muteParticipant, disableParticipantVideo, + grantControl, + revokeControl, + localHasControl, }: { participant: Participant & { isLocal?: boolean }; isLocal: boolean; @@ -148,6 +157,9 @@ function ParticipantItem({ session: Session; muteParticipant: (participantId: string) => void; disableParticipantVideo: (participantId: string) => void; + grantControl: (participantId: string) => void; + revokeControl: () => void; + localHasControl: boolean; }) { const parentRef = useRef(null); @@ -226,6 +238,11 @@ function ParticipantItem({
)} + {(participant.hasControl || (isLocal && localHasControl)) && ( +
+ +
+ )} {/* Действия только для удаленных участников и только для организатора */} {!isLocal && isOrganizer && ( @@ -254,9 +271,17 @@ function ParticipantItem({ }, { icon: , - label: "Передать управление", + label: participant.hasControl + ? "Забрать управление" + : "Передать управление", onClick: () => { - console.log("Grant control:", participant.id); + if (participant.hasControl) { + console.log("Revoke control"); + revokeControl(); + } else { + console.log("Grant control:", participant.id); + grantControl(participant.id); + } }, }, { diff --git a/client/src/components/ui/ControlButton.tsx b/client/src/components/ui/ControlButton.tsx index d01412b..41bb777 100644 --- a/client/src/components/ui/ControlButton.tsx +++ b/client/src/components/ui/ControlButton.tsx @@ -1,6 +1,4 @@ import clsx from "clsx"; -import Warning from "../indicators/Warning"; -import Tooltip from "./Tooltip"; interface ControlButtonProps extends React.ButtonHTMLAttributes { @@ -39,14 +37,6 @@ function ControlButton({ > {icon} - {disabled && size === "large" && ( - - - - )} ); } diff --git a/client/src/components/ui/ControlsPopover.tsx b/client/src/components/ui/ControlsPopover.tsx index 30b5717..bcb7e36 100644 --- a/client/src/components/ui/ControlsPopover.tsx +++ b/client/src/components/ui/ControlsPopover.tsx @@ -6,13 +6,13 @@ import Button from "./Button"; import ChatFilledIcon from "../icons/ChatFilledIcon"; import UsersFilledIcon from "../icons/UsersFilledIcon"; import ShareFilledIcon from "../icons/ShareFilledIcon"; -import CogFilledIcon from "../icons/CogFilledIcon"; +// import CogFilledIcon from "../icons/CogFilledIcon"; import usePopupStore from "../../store/popupStore"; -import useModalStore from "../../store/modalStore"; +// import useModalStore from "../../store/modalStore"; import ChatPopup from "../popups/ChatPopup"; import ParticipantsPopup from "../popups/ParticipantsPopup"; import SharePopup from "../popups/SharePopup"; -import SettingsModal from "../modals/SettingsModal"; +// import SettingsModal from "../modals/SettingsModal"; import clsx from "clsx"; import { useClickAway } from "@uidotdev/usehooks"; import type { Session } from "../../types/Session"; @@ -28,7 +28,7 @@ function ControlsPopover({ session, parentRef }: ControlsPopoverProps) { const ref = useClickAway(() => setIsOpened(false)); const { setPopup } = usePopupStore(); - const { setModal } = useModalStore(); + // const { setModal } = useModalStore(); function handleClickOpenChatPopup() { setIsOpened(false); @@ -50,10 +50,10 @@ function ControlsPopover({ session, parentRef }: ControlsPopoverProps) { ); } - function handleClickOpenSettingsModal() { - setIsOpened(false); - setModal(); - } + // function handleClickOpenSettingsModal() { + // setIsOpened(false); + // setModal(); + // } return (
@@ -102,7 +102,7 @@ function ControlsPopover({ session, parentRef }: ControlsPopoverProps) {
Пригласить - + */} ); diff --git a/client/src/components/ui/UserCamera.tsx b/client/src/components/ui/UserCamera.tsx index c12f1e9..7cbe303 100644 --- a/client/src/components/ui/UserCamera.tsx +++ b/client/src/components/ui/UserCamera.tsx @@ -35,6 +35,8 @@ interface UserCameraProps { participantId?: string; // ID участника для управления onMuteParticipant?: (participantId: string) => void; onDisableParticipantVideo?: (participantId: string) => void; + onGrantControl?: (participantId: string) => void; + onRevokeControl?: () => void; } export default function UserCamera({ @@ -54,6 +56,8 @@ export default function UserCamera({ participantId, onMuteParticipant, onDisableParticipantVideo, + onGrantControl, + // onRevokeControl, }: UserCameraProps) { const ref = useRef(null); const actionsPopoverParentRef = useRef(null); @@ -448,7 +452,10 @@ export default function UserCamera({ icon: , label: "Передать управление", onClick: () => { - console.log("Grant control:", participantId); + if (onGrantControl && participantId) { + console.log("Grant control:", participantId); + onGrantControl(participantId); + } }, }, { diff --git a/client/src/components/ui/UserDevicesControls.tsx b/client/src/components/ui/UserDevicesControls.tsx index fed9b6a..1898237 100644 --- a/client/src/components/ui/UserDevicesControls.tsx +++ b/client/src/components/ui/UserDevicesControls.tsx @@ -3,9 +3,9 @@ import ControlButton from "./ControlButton"; import VideoFilledIcon from "../icons/VideoFilledIcon"; import VideoOffFilledIcon from "../icons/VideoOffFilledIcon"; import HandRaisedFilledIcon from "../icons/HandRaisedFilledIcon"; -import CogFilledIcon from "../icons/CogFilledIcon"; -import useModalStore from "../../store/modalStore"; -import SettingsModal from "../modals/SettingsModal"; +// import CogFilledIcon from "../icons/CogFilledIcon"; +// import useModalStore from "../../store/modalStore"; +// import SettingsModal from "../modals/SettingsModal"; import MicrophoneOffFilledIcon from "../icons/MicrophoneOffFilledIcon"; import clsx from "clsx"; @@ -16,6 +16,9 @@ export interface UserDevicesControlsProps { isVideoMuted: boolean; hasLocalStream?: boolean; mode?: "full" | "mini"; + hasControl?: boolean; + onToggleControl?: () => void; + isOrganizer?: boolean; } export default function UserDevicesControls({ @@ -25,12 +28,15 @@ export default function UserDevicesControls({ isVideoMuted, hasLocalStream = true, mode = "full", + hasControl = false, + onToggleControl, + isOrganizer = false, }: UserDevicesControlsProps) { - const { setModal } = useModalStore(); + // const { setModal } = useModalStore(); - function ToggleSettings() { - setModal(); - } + // function ToggleSettings() { + // setModal(); + // } return (
e.stopPropagation()} size="large" - icon={} - onClick={() => console.log("Toggle can control")} + icon={ +
+ + {!hasControl && ( +
+
+
+ )} +
+ } + onClick={() => { + // У организатора кнопка возвращает управление себе, только если его нет + if (isOrganizer && !hasControl && onToggleControl) { + onToggleControl(); + } + }} + className={hasControl ? "bg-[#7B60F3]" : ""} + disabled={!isOrganizer} /> - e.stopPropagation()} size="large" icon={} onClick={ToggleSettings} - /> + /> */}
); } diff --git a/client/src/hooks/useWebRTC.ts b/client/src/hooks/useWebRTC.ts index 35e5855..17deffe 100644 --- a/client/src/hooks/useWebRTC.ts +++ b/client/src/hooks/useWebRTC.ts @@ -15,18 +15,35 @@ export const useWebRTC = (roomId?: string, autoJoin = false) => { const [participants, setParticipants] = useState([]); // Начальное состояние берем из сервиса, если он уже существует const [isAudioMuted, setIsAudioMuted] = useState(() => { - const initialAudioMuted = webrtcServiceInstance ? webrtcServiceInstance.isAudioMuted() : true; + const initialAudioMuted = webrtcServiceInstance + ? webrtcServiceInstance.isAudioMuted() + : true; console.log(`[useWebRTC INIT] Initial isAudioMuted: ${initialAudioMuted}`); return initialAudioMuted; }); const [isVideoMuted, setIsVideoMuted] = useState(() => { - const initialVideoMuted = webrtcServiceInstance ? webrtcServiceInstance.isVideoMuted() : true; + const initialVideoMuted = webrtcServiceInstance + ? webrtcServiceInstance.isVideoMuted() + : true; console.log(`[useWebRTC INIT] Initial isVideoMuted: ${initialVideoMuted}`); return initialVideoMuted; }); const [chatMessages, setChatMessages] = useState([]); const [isConnected, setIsConnected] = useState(false); const [isInitialized, setIsInitialized] = useState(false); + const [hasControl, setHasControl] = useState(() => { + const initialControl = webrtcServiceInstance + ? webrtcServiceInstance.hasControl() + : false; + console.log(`[useWebRTC INIT] Initial hasControl: ${initialControl}`); + return initialControl; + }); + const hasControlRef = useRef(hasControl); + + // Синхронизируем ref при изменении состояния + useEffect(() => { + hasControlRef.current = hasControl; + }, [hasControl]); // Логируем начальное состояние при монтировании console.log( @@ -67,15 +84,24 @@ export const useWebRTC = (roomId?: string, autoJoin = false) => { } const existingParticipants = webrtcServiceInstance.getParticipants(); - console.log("[useWebRTC] Component mounted, existing participants:", existingParticipants.length); + console.log( + "[useWebRTC] Component mounted, existing participants:", + existingParticipants.length + ); if (existingParticipants.length > 0) { - console.log("[useWebRTC] Initializing with participants:", existingParticipants.map(p => p.id)); + console.log( + "[useWebRTC] Initializing with participants:", + existingParticipants.map((p) => p.id) + ); setParticipants(existingParticipants); } const existingMessages = webrtcServiceInstance.getChatMessages(); if (existingMessages.length > 0) { - console.log("[useWebRTC] Initializing with existing messages:", existingMessages.length); + console.log( + "[useWebRTC] Initializing with existing messages:", + existingMessages.length + ); setChatMessages(existingMessages); } @@ -95,27 +121,40 @@ export const useWebRTC = (roomId?: string, autoJoin = false) => { // Если текущее состояние true (выключено по умолчанию), устанавливаем false (включено) // Иначе оставляем как есть (пользователь мог уже изменить) const newState = current === true ? false : current; - console.log(`[useWebRTC] onLocalStreamReady: isAudioMuted ${current} -> ${newState}`); + console.log( + `[useWebRTC] onLocalStreamReady: isAudioMuted ${current} -> ${newState}` + ); return newState; }); setIsVideoMuted((current) => { const newState = current === true ? false : current; - console.log(`[useWebRTC] onLocalStreamReady: isVideoMuted ${current} -> ${newState}`); + console.log( + `[useWebRTC] onLocalStreamReady: isVideoMuted ${current} -> ${newState}` + ); return newState; }); setIsInitialized(true); }, onRemoteStreamReady: (participantId, stream) => { - console.log("[useWebRTC] onRemoteStreamReady called for:", participantId); + console.log( + "[useWebRTC] onRemoteStreamReady called for:", + participantId + ); setParticipants((prev) => { const existing = prev.find((p) => p.id === participantId); if (existing) { - console.log("[useWebRTC] Updating stream for existing participant:", participantId); + console.log( + "[useWebRTC] Updating stream for existing participant:", + participantId + ); return prev.map((p) => p.id === participantId ? { ...p, stream } : p ); } else { - console.log("[useWebRTC] Adding new participant with stream:", participantId); + console.log( + "[useWebRTC] Adding new participant with stream:", + participantId + ); return [...prev, { id: participantId, stream }]; } }); @@ -124,7 +163,10 @@ export const useWebRTC = (roomId?: string, autoJoin = false) => { setIsConnected(true); }, onParticipantJoined: (participant) => { - console.log("[useWebRTC] onParticipantJoined called for:", participant.id); + console.log( + "[useWebRTC] onParticipantJoined called for:", + participant.id + ); setParticipants((prev) => { if (prev.find((p) => p.id === participant.id)) { console.log("[useWebRTC] Participant already in list, skipping"); @@ -146,7 +188,9 @@ export const useWebRTC = (roomId?: string, autoJoin = false) => { setIsVideoMuted(!isEnabled); }, onParticipantAudioToggle: (participantId, isEnabled) => { - console.log(`[useWebRTC] Audio toggle for ${participantId}: ${isEnabled}`); + console.log( + `[useWebRTC] Audio toggle for ${participantId}: ${isEnabled}` + ); setParticipants((prev) => prev.map((p) => p.id === participantId ? { ...p, isMuted: !isEnabled } : p @@ -154,7 +198,9 @@ export const useWebRTC = (roomId?: string, autoJoin = false) => { ); }, onParticipantVideoToggle: (participantId, isEnabled) => { - console.log(`[useWebRTC] Video toggle for ${participantId}: ${isEnabled}`); + console.log( + `[useWebRTC] Video toggle for ${participantId}: ${isEnabled}` + ); setParticipants((prev) => prev.map((p) => p.id === participantId ? { ...p, isVideoOff: !isEnabled } : p @@ -163,15 +209,31 @@ export const useWebRTC = (roomId?: string, autoJoin = false) => { }, onParticipantSpeakingChange: (participantId, isSpeaking) => { setParticipants((prev) => - prev.map((p) => - p.id === participantId ? { ...p, isSpeaking } : p - ) + prev.map((p) => (p.id === participantId ? { ...p, isSpeaking } : p)) ); }, onChatMessage: (message) => { console.log("[useWebRTC] onChatMessage called:", message); setChatMessages((prev) => [...prev, message]); }, + onParticipantControlToggle: (participantId, hasControl) => { + console.log( + `[useWebRTC] Participant ${participantId} control toggle: ${hasControl}` + ); + setParticipants((prev) => + prev.map((p) => (p.id === participantId ? { ...p, hasControl } : p)) + ); + }, + onControlGranted: () => { + console.log("[useWebRTC] Local user granted control"); + hasControlRef.current = true; + setHasControl(true); + }, + onControlRevoked: () => { + console.log("[useWebRTC] Local user revoked control"); + hasControlRef.current = false; + setHasControl(false); + }, onError: (error) => { console.error("[useWebRTC] Error:", error); }, @@ -191,15 +253,19 @@ export const useWebRTC = (roomId?: string, autoJoin = false) => { try { isInitializing = true; const stream = await webrtcServiceInstance.initializeLocalStream(); - + // Даже если stream === null (пользователь отказался от разрешений), // считаем инициализацию завершенной if (stream === null) { - console.log("[useWebRTC] Initialized without local stream (user denied permissions)"); + console.log( + "[useWebRTC] Initialized without local stream (user denied permissions)" + ); // Обновляем состояние muted на основе реального состояния сервиса const audioMuted = webrtcServiceInstance.isAudioMuted(); const videoMuted = webrtcServiceInstance.isVideoMuted(); - console.log(`[useWebRTC] Setting isAudioMuted=${audioMuted}, isVideoMuted=${videoMuted}`); + console.log( + `[useWebRTC] Setting isAudioMuted=${audioMuted}, isVideoMuted=${videoMuted}` + ); setIsAudioMuted(audioMuted); setIsVideoMuted(videoMuted); setIsInitialized(true); @@ -210,7 +276,9 @@ export const useWebRTC = (roomId?: string, autoJoin = false) => { // Обновляем состояние muted на основе реального состояния сервиса const audioMuted = webrtcServiceInstance.isAudioMuted(); const videoMuted = webrtcServiceInstance.isVideoMuted(); - console.log(`[useWebRTC] Error case - Setting isAudioMuted=${audioMuted}, isVideoMuted=${videoMuted}`); + console.log( + `[useWebRTC] Error case - Setting isAudioMuted=${audioMuted}, isVideoMuted=${videoMuted}` + ); setIsAudioMuted(audioMuted); setIsVideoMuted(videoMuted); setIsInitialized(true); @@ -244,6 +312,13 @@ export const useWebRTC = (roomId?: string, autoJoin = false) => { const joinRoomAsync = async () => { await webrtcServiceInstance!.joinRoom(roomId); hasJoinedRoomRef.current = true; + // Синхронизируем состояние управления после присоединения + const currentHasControl = webrtcServiceInstance!.hasControl(); + hasControlRef.current = currentHasControl; + setHasControl(currentHasControl); + console.log( + `[useWebRTC] Synced hasControl after join: ${currentHasControl}` + ); }; joinRoomAsync(); @@ -252,7 +327,9 @@ export const useWebRTC = (roomId?: string, autoJoin = false) => { const toggleAudio = () => { if (!webrtcServiceInstance) return; const newState = webrtcServiceInstance.toggleAudio(); - console.log(`[useWebRTC] toggleAudio: newState=${newState}, setting isAudioMuted=${!newState}`); + console.log( + `[useWebRTC] toggleAudio: newState=${newState}, setting isAudioMuted=${!newState}` + ); setIsAudioMuted(!newState); console.log(`[useWebRTC] setIsAudioMuted called with ${!newState}`); }; @@ -260,12 +337,18 @@ export const useWebRTC = (roomId?: string, autoJoin = false) => { const toggleVideo = () => { if (!webrtcServiceInstance) return; const newState = webrtcServiceInstance.toggleVideo(); - console.log(`[useWebRTC] toggleVideo: newState=${newState}, setting isVideoMuted=${!newState}`); + console.log( + `[useWebRTC] toggleVideo: newState=${newState}, setting isVideoMuted=${!newState}` + ); setIsVideoMuted(!newState); console.log(`[useWebRTC] setIsVideoMuted called with ${!newState}`); }; - const sendMessage = (content: string, senderName?: string, isAuthenticated?: boolean) => { + const sendMessage = ( + content: string, + senderName?: string, + isAuthenticated?: boolean + ) => { if (!webrtcServiceInstance) return; webrtcServiceInstance.sendChatMessage(content, senderName, isAuthenticated); }; @@ -303,6 +386,16 @@ export const useWebRTC = (roomId?: string, autoJoin = false) => { webrtcServiceInstance.updateUserId(newUserId); }; + const grantControl = (participantId: string) => { + if (!webrtcServiceInstance) return; + webrtcServiceInstance.grantControl(participantId); + }; + + const revokeControl = () => { + if (!webrtcServiceInstance) return; + webrtcServiceInstance.revokeControl(); + }; + return { localStream, participants, @@ -321,5 +414,8 @@ export const useWebRTC = (roomId?: string, autoJoin = false) => { leaveRoom, muteParticipant, disableParticipantVideo, + grantControl, + revokeControl, + hasControl, }; }; diff --git a/client/src/lib/webrtc.ts b/client/src/lib/webrtc.ts index 9fb0be3..c83a30c 100644 --- a/client/src/lib/webrtc.ts +++ b/client/src/lib/webrtc.ts @@ -19,6 +19,7 @@ export interface Participant { isMuted?: boolean; isVideoOff?: boolean; isSpeaking?: boolean; + hasControl?: boolean; iceCandidateQueue?: RTCIceCandidate[]; } @@ -43,6 +44,12 @@ export interface WebRTCCallbacks { participantId: string, isSpeaking: boolean ) => void; + onParticipantControlToggle?: ( + participantId: string, + hasControl: boolean + ) => void; + onControlGranted?: () => void; + onControlRevoked?: () => void; onError?: (error: Error) => void; } @@ -54,6 +61,7 @@ interface WebRTCState { userId: string; isAudioEnabled: boolean; isVideoEnabled: boolean; + hasControl: boolean; callbacks: WebRTCCallbacks[]; // Изменено на массив chatMessages: ChatMessage[]; } @@ -135,6 +143,7 @@ export function createWebRTCService(callbacks: WebRTCCallbacks = {}) { userId, isAudioEnabled: true, isVideoEnabled: true, + hasControl: false, callbacks: [callbacks], // Массив коллбэков chatMessages: [], }; @@ -167,7 +176,12 @@ export function createWebRTCService(callbacks: WebRTCCallbacks = {}) { }, updateUserId: (newUserId: string) => { if (state) { - console.log("[WebRTC] Updating userId from", state.userId, "to", newUserId); + console.log( + "[WebRTC] Updating userId from", + state.userId, + "to", + newUserId + ); state.userId = newUserId; } }, @@ -177,15 +191,35 @@ export function createWebRTCService(callbacks: WebRTCCallbacks = {}) { getLocalStream: () => state?.localStream || null, isAudioMuted: () => { const result = state ? !state.isAudioEnabled : true; - console.log(`[WebRTC] isAudioMuted() called: state.isAudioEnabled=${state?.isAudioEnabled}, returning ${result}`); + console.log( + `[WebRTC] isAudioMuted() called: state.isAudioEnabled=${state?.isAudioEnabled}, returning ${result}` + ); return result; }, isVideoMuted: () => { const result = state ? !state.isVideoEnabled : true; - console.log(`[WebRTC] isVideoMuted() called: state.isVideoEnabled=${state?.isVideoEnabled}, returning ${result}`); + console.log( + `[WebRTC] isVideoMuted() called: state.isVideoEnabled=${state?.isVideoEnabled}, returning ${result}` + ); return result; }, + hasControl: () => state?.hasControl || false, hasLocalStream: () => state?.localStream !== null, + grantControl: (participantId: string) => { + if (!state?.roomId) return; + console.log(`[WebRTC] Granting control to ${participantId}`); + state.socket.emit("grant-control", { + roomId: state.roomId, + targetUserId: participantId, + }); + }, + revokeControl: () => { + if (!state?.roomId) return; + console.log("[WebRTC] Revoking control"); + state.socket.emit("revoke-control", { + roomId: state.roomId, + }); + }, addCallbacks: (newCallbacks: WebRTCCallbacks) => { if (state) { state.callbacks.push(newCallbacks); @@ -342,7 +376,7 @@ function setupSocketListeners() { // Обработка общих ошибок от сервера socket.on("error", (error: { message: string }) => { console.error("[WebRTC] Error received from server:", error); - + // Показываем пользователю понятное сообщение if (error.message.includes("Unauthorized")) { alert("У вас нет прав для выполнения этого действия"); @@ -351,43 +385,56 @@ function setupSocketListeners() { } else { alert(`Ошибка: ${error.message}`); } - + callAllCallbacks("onError", new Error(error.message)); }); // Audio/Video toggle handlers - socket.on("audio-toggle", ({ userId, isEnabled }: { userId: string; isEnabled: boolean }) => { - console.log(`[WebRTC] Received audio-toggle from ${userId}: ${isEnabled}`); - if (!state) return; + socket.on( + "audio-toggle", + ({ userId, isEnabled }: { userId: string; isEnabled: boolean }) => { + console.log( + `[WebRTC] Received audio-toggle from ${userId}: ${isEnabled}` + ); + if (!state) return; - const participant = state.participants.get(userId); - if (participant) { - participant.isMuted = !isEnabled; - callAllCallbacks("onParticipantAudioToggle", userId, isEnabled); + const participant = state.participants.get(userId); + if (participant) { + participant.isMuted = !isEnabled; + callAllCallbacks("onParticipantAudioToggle", userId, isEnabled); + } } - }); + ); - socket.on("video-toggle", ({ userId, isEnabled }: { userId: string; isEnabled: boolean }) => { - console.log(`[WebRTC] Received video-toggle from ${userId}: ${isEnabled}`); - if (!state) return; + socket.on( + "video-toggle", + ({ userId, isEnabled }: { userId: string; isEnabled: boolean }) => { + console.log( + `[WebRTC] Received video-toggle from ${userId}: ${isEnabled}` + ); + if (!state) return; - const participant = state.participants.get(userId); - if (participant) { - participant.isVideoOff = !isEnabled; - callAllCallbacks("onParticipantVideoToggle", userId, isEnabled); + const participant = state.participants.get(userId); + if (participant) { + participant.isVideoOff = !isEnabled; + callAllCallbacks("onParticipantVideoToggle", userId, isEnabled); + } } - }); + ); - socket.on("speaking-state", ({ userId, isSpeaking }: { userId: string; isSpeaking: boolean }) => { - if (!state) return; + socket.on( + "speaking-state", + ({ userId, isSpeaking }: { userId: string; isSpeaking: boolean }) => { + if (!state) return; - const participant = state.participants.get(userId); - if (participant) { - participant.isSpeaking = isSpeaking; - // Уведомляем callback для обновления UI - callAllCallbacks("onParticipantSpeakingChange", userId, isSpeaking); + const participant = state.participants.get(userId); + if (participant) { + participant.isSpeaking = isSpeaking; + // Уведомляем callback для обновления UI + callAllCallbacks("onParticipantSpeakingChange", userId, isSpeaking); + } } - }); + ); // Обработка принудительного выключения микрофона socket.on("force-mute-audio", () => { @@ -399,7 +446,7 @@ function setupSocketListeners() { track.enabled = false; }); state.isAudioEnabled = false; - + // Уведомляем все компоненты об изменении callAllCallbacks("onLocalAudioToggle", false); console.log("[WebRTC] Audio forcefully muted by admin"); @@ -415,12 +462,58 @@ function setupSocketListeners() { track.enabled = false; }); state.isVideoEnabled = false; - + // Уведомляем все компоненты об изменении callAllCallbacks("onLocalVideoToggle", false); console.log("[WebRTC] Video forcefully disabled by admin"); }); + // Обработка событий управления PixelStreaming + socket.on( + "control-toggle", + ({ userId, hasControl }: { userId: string; hasControl: boolean }) => { + console.log(`[WebRTC] Control toggle for ${userId}: ${hasControl}`); + if (!state) return; + + // Если это текущий пользователь, обновляем локальное состояние + if (userId === state.userId) { + // Вызываем коллбэк только если состояние действительно изменилось + const previousControl = state.hasControl; + state.hasControl = hasControl; + if (hasControl && !previousControl) { + callAllCallbacks("onControlGranted"); + } else if (!hasControl && previousControl) { + callAllCallbacks("onControlRevoked"); + } + return; + } + + // Обновляем состояние для участника + // Если участника еще нет, добавляем его + let participant = state.participants.get(userId); + if (!participant) { + participant = addParticipant(userId); + } + + participant.hasControl = hasControl; + callAllCallbacks("onParticipantControlToggle", userId, hasControl); + } + ); + + socket.on("control-granted", () => { + console.log("[WebRTC] Received control-granted event"); + if (!state) return; + state.hasControl = true; + callAllCallbacks("onControlGranted"); + }); + + socket.on("control-revoked", () => { + console.log("[WebRTC] Received control-revoked event"); + if (!state) return; + state.hasControl = false; + callAllCallbacks("onControlRevoked"); + }); + console.log("Socket listeners set up complete"); } @@ -476,7 +569,9 @@ async function initializeLocalStream(): Promise { // Устанавливаем состояние аудио и видео в false, так как нет доступа к медиа state.isAudioEnabled = false; state.isVideoEnabled = false; - console.log("[WebRTC] Set isAudioEnabled and isVideoEnabled to false (no media access)"); + console.log( + "[WebRTC] Set isAudioEnabled and isVideoEnabled to false (no media access)" + ); // Возвращаем null вместо выброса ошибки, чтобы пользователь мог продолжить return null; @@ -486,11 +581,19 @@ async function initializeLocalStream(): Promise { async function joinRoom(roomId: string): Promise { if (!state) throw new Error("WebRTC service not initialized"); + // Проверяем, не присоединились ли мы уже к этой комнате + if (state.roomId === roomId) { + console.log(`[WebRTC] Already in room ${roomId}, skipping join`); + return; + } + console.log("Joining room:", roomId, "with user ID:", state.userId); - console.log(`[WebRTC] Joining with isAudioEnabled=${state.isAudioEnabled}, isVideoEnabled=${state.isVideoEnabled}`); + console.log( + `[WebRTC] Joining with isAudioEnabled=${state.isAudioEnabled}, isVideoEnabled=${state.isVideoEnabled}` + ); state.roomId = roomId; - state.socket.emit("join-room", { - roomId, + state.socket.emit("join-room", { + roomId, userId: state.userId, isAudioEnabled: state.isAudioEnabled, isVideoEnabled: state.isVideoEnabled, @@ -884,10 +987,10 @@ function toggleAudio(): boolean { track.enabled = !track.enabled; }); state.isAudioEnabled = !state.isAudioEnabled; - + // Уведомляем все компоненты об изменении локального аудио callAllCallbacks("onLocalAudioToggle", state.isAudioEnabled); - + // Отправляем обновление состояния аудио всем участникам if (state.roomId) { state.socket.emit("audio-toggle", { @@ -897,7 +1000,7 @@ function toggleAudio(): boolean { }); console.log(`[WebRTC] Sent audio-toggle: ${state.isAudioEnabled}`); } - + return state.isAudioEnabled; } @@ -917,10 +1020,10 @@ function toggleVideo(): boolean { track.enabled = !track.enabled; }); state.isVideoEnabled = !state.isVideoEnabled; - + // Уведомляем все компоненты об изменении локального видео callAllCallbacks("onLocalVideoToggle", state.isVideoEnabled); - + // Отправляем обновление состояния видео всем участникам if (state.roomId) { state.socket.emit("video-toggle", { @@ -930,7 +1033,7 @@ function toggleVideo(): boolean { }); console.log(`[WebRTC] Sent video-toggle: ${state.isVideoEnabled}`); } - + return state.isVideoEnabled; } @@ -945,11 +1048,20 @@ function updateSpeakingState(isSpeaking: boolean): void { }); } -function sendChatMessage(content: string, senderName?: string, isAuthenticated?: boolean): void { +function sendChatMessage( + content: string, + senderName?: string, + isAuthenticated?: boolean +): void { if (!state || !content.trim() || !state.roomId) return; console.log("📤 Sending message via Socket.IO:", content); - console.log("📤 isAuthenticated:", isAuthenticated, "state.userId:", state.userId); + console.log( + "📤 isAuthenticated:", + isAuthenticated, + "state.userId:", + state.userId + ); // Определяем userId и guestId на основе реальной авторизации // Если пользователь авторизован, state.userId содержит настоящий userId diff --git a/client/src/main.tsx b/client/src/main.tsx index 3c06e3d..0294a18 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -14,6 +14,7 @@ import PopupContainer from "./components/PopupContainer"; import ToastsContainer from "./components/toasts/ToastsContainer"; import TestPage from "./pages/TestPage"; import SessionPage from "./pages/SessionPage"; +import { Toaster } from "react-hot-toast"; const router = createBrowserRouter([ { @@ -56,5 +57,24 @@ createRoot(document.getElementById("root")!).render( + ); diff --git a/client/src/pages/SessionPage.tsx b/client/src/pages/SessionPage.tsx index dc23ada..10a25a6 100644 --- a/client/src/pages/SessionPage.tsx +++ b/client/src/pages/SessionPage.tsx @@ -30,6 +30,7 @@ import clsx from "clsx"; import { useMe } from "../hooks/useAuth"; import QuitSessionModal from "../components/modals/QuitSessionModal"; import useModalStore from "../store/modalStore"; +import toast from "react-hot-toast"; function SessionPage() { const { setPopup, popupType } = usePopupStore(); @@ -104,11 +105,13 @@ function SessionPage() { ); } - const [mode, setMode] = useState<"full" | "mini">("mini"); + const [mode] = useState<"full" | "mini">("mini"); + // const [mode, setMode] = useState<"full" | "mini">("mini"); + const [isVideoInitialized, setIsVideoInitialized] = useState(false); - function toggleMode() { - setMode(mode === "full" ? "mini" : "full"); - } + // function toggleMode() { + // setMode(mode === "full" ? "mini" : "full"); + // } function handleQuitSessionModalOpen() { setModal( navigate("/test")} />); @@ -121,9 +124,27 @@ function SessionPage() { toggleVideo, isVideoMuted, participants, + hasControl, } = useWebRTC(session?.id, true); const actionsSidebarRef = useRef(null); + const prevHasControlRef = useRef(hasControl); + + // Отслеживаем изменения hasControl и показываем toast + useEffect(() => { + if (hasControl && !prevHasControlRef.current) { + toast.success("Управление получено", { + duration: 3000, + position: "top-center", + }); + } + prevHasControlRef.current = hasControl; + }, [hasControl]); + + // Сбрасываем состояние инициализации видео при изменении параметров сессии + useEffect(() => { + setIsVideoInitialized(false); + }, [session?.server?.localIp, session?.playerPort]); if (isLoading) { return ( @@ -168,7 +189,7 @@ function SessionPage() { return (
{/* Pixel Streaming - показывается только когда сессия активна */} @@ -179,7 +200,8 @@ function SessionPage() {
{ console.log("Video initialized"); + setIsVideoInitialized(true); }} /> + {/* Лоадер загрузки видео */} + {!isVideoInitialized && ( +
+
+
+ +
+
Загрузка...
+
+
+ )} + {/* Блокирующий overlay когда нет прав управления */} + {!hasControl && ( +
{ + toast.error( + "Управление недоступно. Запросите права у организатора", + { + duration: 4000, + position: "top-center", + } + ); + }} + /> + )}
)} @@ -212,11 +262,11 @@ function SessionPage() { )} - + {/*
{mode === "full" ? : }
-
+
*/} (); const users = new Map(); +// Таймеры для автоматического завершения сессий (roomId -> NodeJS.Timeout) +const sessionEndTimers = new Map(); // Вспомогательные функции 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 + ); + }); } } } diff --git a/session-server/pm2.config.cjs b/session-server/pm2.config.cjs new file mode 100644 index 0000000..2d4ecc0 --- /dev/null +++ b/session-server/pm2.config.cjs @@ -0,0 +1,9 @@ +module.exports = { + apps: [ + { + name: "session-server", + interpreter: "bun", + script: "./dist/index.js", + }, + ], +}; diff --git a/session-server/src/index.ts b/session-server/src/index.ts index 3b0c3c9..54079dc 100644 --- a/session-server/src/index.ts +++ b/session-server/src/index.ts @@ -192,6 +192,8 @@ function getGpuFreeMb(): number { { encoding: "utf-8", timeout: 5000, // 5 секунд таймаут + windowsHide: true, // Скрыть окно консоли на Windows + stdio: "pipe", // Перенаправить вывод в pipe вместо создания нового окна } ); @@ -754,6 +756,7 @@ function killProcessTree(pid: number): void { execSync(`taskkill /pid ${pid} /T /F`, { stdio: "ignore", timeout: 10000, + windowsHide: true, // Скрыть окно консоли на Windows }); console.log( `[${new Date().toISOString()}] ✅ Дерево процессов для PID ${pid} успешно завершено`