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
+2 -2
View File
@@ -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
+5
View File
@@ -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=="],
+1
View File
@@ -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",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 670 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 KiB

@@ -11,11 +11,13 @@ import type { AllSettings } from "@epicgames-ps/lib-pixelstreamingfrontend-ue5.7
export interface PixelStreamingWrapperProps {
initialSettings?: Partial<AllSettings>;
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<HTMLDivElement>(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 (
<div
style={{
@@ -137,6 +159,7 @@ export const PixelStreamingWrapper = ({
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
zIndex: 20,
}}
onClick={() => {
pixelStreaming?.play();
+17 -2
View File
@@ -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}
/>
</DraggableContainer>
);
@@ -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<HTMLDivElement>(null);
@@ -103,6 +106,9 @@ export default function ParticipantsPopup({ session }: ParticipantsPopupProps) {
session={session}
muteParticipant={muteParticipant}
disableParticipantVideo={disableParticipantVideo}
grantControl={grantControl}
revokeControl={revokeControl}
localHasControl={localHasControl}
/>
<hr className="w-full 2xl:h-[0.69vw] h-px border-[#F6F6F6] last:hidden" />
</Fragment>
@@ -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<HTMLDivElement>(null);
@@ -226,6 +238,11 @@ function ParticipantItem({
<VideoOffFilledIcon />
</div>
)}
{(participant.hasControl || (isLocal && localHasControl)) && (
<div className="2xl:size-[1.111vw] size-4 text-[#7B60F3]">
<HandRaisedFilledIcon />
</div>
)}
{/* Действия только для удаленных участников и только для организатора */}
{!isLocal && isOrganizer && (
@@ -254,9 +271,17 @@ function ParticipantItem({
},
{
icon: <HandRaisedFilledIcon />,
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);
}
},
},
{
@@ -1,6 +1,4 @@
import clsx from "clsx";
import Warning from "../indicators/Warning";
import Tooltip from "./Tooltip";
interface ControlButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
@@ -39,14 +37,6 @@ function ControlButton({
>
{icon}
</div>
{disabled && size === "large" && (
<Tooltip
label="Нет доступа"
className="absolute 2xl:-top-[0.139vw] 2xl:-right-[0.139vw] -top-0.5 -right-0.5"
>
<Warning type="critical" className="text-white" />
</Tooltip>
)}
</button>
);
}
+10 -10
View File
@@ -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<HTMLDivElement>(() => 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(<SettingsModal />);
}
// function handleClickOpenSettingsModal() {
// setIsOpened(false);
// setModal(<SettingsModal />);
// }
return (
<div className="order-3 2xl:hidden z-[9999]" ref={ref}>
@@ -102,7 +102,7 @@ function ControlsPopover({ session, parentRef }: ControlsPopoverProps) {
</div>
Пригласить
</Button>
<Button
{/* <Button
variant="tertiary"
className="w-full !justify-start"
onClick={handleClickOpenSettingsModal}
@@ -111,7 +111,7 @@ function ControlsPopover({ session, parentRef }: ControlsPopoverProps) {
<CogFilledIcon />
</div>
Настройки
</Button>
</Button> */}
</PopoverWrapper>
</div>
);
+8 -1
View File
@@ -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<HTMLVideoElement>(null);
const actionsPopoverParentRef = useRef<HTMLDivElement>(null);
@@ -448,7 +452,10 @@ export default function UserCamera({
icon: <HandRaisedFilledIcon />,
label: "Передать управление",
onClick: () => {
console.log("Grant control:", participantId);
if (onGrantControl && participantId) {
console.log("Grant control:", participantId);
onGrantControl(participantId);
}
},
},
{
@@ -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(<SettingsModal />);
}
// function ToggleSettings() {
// setModal(<SettingsModal />);
// }
return (
<div
@@ -70,15 +76,31 @@ export default function UserDevicesControls({
<ControlButton
onMouseDown={(e) => e.stopPropagation()}
size="large"
icon={<HandRaisedFilledIcon />}
onClick={() => console.log("Toggle can control")}
icon={
<div className="relative">
<HandRaisedFilledIcon />
{!hasControl && (
<div className="flex absolute inset-0 justify-center items-center">
<div className="2xl:w-[1.389vw] 2xl:h-[0.139vw] w-5 h-[2px] bg-white rotate-45" />
</div>
)}
</div>
}
onClick={() => {
// У организатора кнопка возвращает управление себе, только если его нет
if (isOrganizer && !hasControl && onToggleControl) {
onToggleControl();
}
}}
className={hasControl ? "bg-[#7B60F3]" : ""}
disabled={!isOrganizer}
/>
<ControlButton
{/* <ControlButton
onMouseDown={(e) => e.stopPropagation()}
size="large"
icon={<CogFilledIcon />}
onClick={ToggleSettings}
/>
/> */}
</div>
);
}
+119 -23
View File
@@ -15,18 +15,35 @@ export const useWebRTC = (roomId?: string, autoJoin = false) => {
const [participants, setParticipants] = useState<Participant[]>([]);
// Начальное состояние берем из сервиса, если он уже существует
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<ChatMessage[]>([]);
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,
};
};
+155 -43
View File
@@ -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<MediaStream | null> {
// Устанавливаем состояние аудио и видео в 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<MediaStream | null> {
async function joinRoom(roomId: string): Promise<void> {
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
+20
View File
@@ -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(
<ModalContainer />
<PopupContainer />
<ToastsContainer />
<Toaster
position="top-center"
toastOptions={{
duration: 4000,
style: {
background: "#fff",
color: "#333",
borderRadius: "8px",
padding: "16px",
boxShadow: "0 4px 40px rgba(15, 16, 17, 0.1)",
},
error: {
iconTheme: {
primary: "#FF4517",
secondary: "#fff",
},
},
}}
/>
</QueryClientProvider>
);
+58 -8
View File
@@ -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(<QuitSessionModal onQuitSession={() => navigate("/test")} />);
@@ -121,9 +124,27 @@ function SessionPage() {
toggleVideo,
isVideoMuted,
participants,
hasControl,
} = useWebRTC(session?.id, true);
const actionsSidebarRef = useRef<HTMLDivElement>(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 (
<div
className={clsx(
"overflow-hidden relative order-3 w-screen h-dvh bg-black touch-none max-2xl:flex max-2xl:portrait:items-center"
"overflow-hidden relative order-3 w-screen bg-black h-dvh touch-none max-2xl:flex max-2xl:portrait:items-center"
)}
>
{/* Pixel Streaming - показывается только когда сессия активна */}
@@ -179,7 +200,8 @@ function SessionPage() {
<div className="absolute z-0 w-full h-full aspect-video">
<PixelStreamingWrapper
initialSettings={{
ss: `ws://${session.server.localIp}:${session.playerPort}`,
ss: `wss://a1.sess.stream.graff.tech/server/${session.server.localIp}:${session.playerPort}/`,
// ss: `ws://${session.server.localIp}:${session.playerPort}`,
AutoPlayVideo: true,
AutoConnect: true,
StartVideoMuted: true,
@@ -187,10 +209,38 @@ function SessionPage() {
WaitForStreamer: true,
StreamerId: "DefaultStreamer",
}}
hasControl={hasControl}
onVideoInitialized={() => {
console.log("Video initialized");
setIsVideoInitialized(true);
}}
/>
{/* Лоадер загрузки видео */}
{!isVideoInitialized && (
<div className="flex absolute inset-0 z-10 justify-center items-center bg-black/80">
<div className="flex flex-col gap-4 items-center">
<div className="size-12 text-[#7B60F3] animate-spin">
<LoaderIcon />
</div>
<div className="text-white">Загрузка...</div>
</div>
</div>
)}
{/* Блокирующий overlay когда нет прав управления */}
{!hasControl && (
<div
className="absolute inset-0 z-[100] pointer-events-auto cursor-pointer"
onClick={() => {
toast.error(
"Управление недоступно. Запросите права у организатора",
{
duration: 4000,
position: "top-center",
}
);
}}
/>
)}
</div>
)}
@@ -212,11 +262,11 @@ function SessionPage() {
)}
<ActionsSidebarWrapper ref={actionsSidebarRef} className="z-[150]">
<FloatingActionButton onClick={toggleMode}>
{/* <FloatingActionButton onClick={toggleMode}>
<div className="2xl:size-[1.111vw] size-4 text-white">
{mode === "full" ? <FullscreenExitIcon /> : <FullscreenIcon />}
</div>
</FloatingActionButton>
</FloatingActionButton> */}
<FloatingActionButton
className="max-2xl:hidden"
onClick={handleChatOpen}
+2 -1
View File
@@ -32,7 +32,8 @@ function TestPage() {
const response = await api
.post("sessions", {
json: {
appId: "2914d736-b928-461c-b58f-e5d35d8b605d",
// appId: "60b0a46a-15f6-40db-8f1e-a288c78f8631",
appId: "1e762115-8bab-4d41-9bec-55c4a4ad3fbe",
mode: "stream",
tier: "demo",
},
+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
);
});
}
}
}
+9
View File
@@ -0,0 +1,9 @@
module.exports = {
apps: [
{
name: "session-server",
interpreter: "bun",
script: "./dist/index.js",
},
],
};
+3
View File
@@ -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} успешно завершено`