Files
stream.graff.tech-new/client/src/components/SessionUsersPanel.tsx
T
inmake 775ba52cd0 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.
2025-12-01 20:23:17 +05:00

291 lines
9.8 KiB
TypeScript

import UserCamera from "./ui/UserCamera";
import UserDevicesControls from "./ui/UserDevicesControls";
import DraggableContainer from "./DraggableContainer";
import { useWebRTC } from "../hooks/useWebRTC";
import clsx from "clsx";
import { useEffect, useRef, useState } from "react";
import type { Session } from "../types/Session";
import { getGuestId } from "../lib/guestId";
import { useMe } from "../hooks/useAuth";
// for test cameras grid
const LOCAL_CAMERAS_COUNT = 1;
interface SessionUsersPanelProps {
roomId: string;
autoJoin?: boolean;
mode?: "full" | "mini";
session?: Session;
}
function SessionUsersPanel({
roomId,
autoJoin = false,
mode = "full",
session,
}: SessionUsersPanelProps) {
const {
localStream,
participants,
isAudioMuted: isLocalAudioMuted,
isVideoMuted: isLocalVideoMuted,
toggleAudio,
toggleVideo,
updateSpeakingState,
muteParticipant,
disableParticipantVideo,
grantControl,
revokeControl,
hasControl: localHasControl,
currentUserId,
} = useWebRTC(roomId, autoJoin);
const hasLocalStream = localStream !== null;
const { data: user } = useMe();
// Определяем, является ли локальный пользователь организатором сессии
const isLocalUserOrganizer = session
? !!(session.userId && user?.id === session.userId) ||
!!(session.guestId && getGuestId() === session.guestId)
: false;
// Функция для определения, является ли конкретный участник организатором
const isParticipantOrganizer = (participantId: string) => {
if (!session) return false;
return (
(session.userId && participantId === session.userId) ||
(session.guestId && participantId === session.guestId)
);
};
// Логируем изменения hasLocalStream для отладки
useEffect(() => {
console.log(
`[SessionUsersPanel] hasLocalStream changed to: ${hasLocalStream}`
);
}, [hasLocalStream]);
// State для хранения состояния speaking
const [localSpeaking, setLocalSpeaking] = useState<boolean>(false);
const lastSentSpeakingRef = useRef<boolean>(false);
const speakingStateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// State для отслеживания размеров и ориентации экрана
const [windowDimensions, setWindowDimensions] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
// Callback для получения изменений состояния speaking от UserCamera
const handleSpeakingChange = (isSpeaking: boolean) => {
setLocalSpeaking(isSpeaking);
};
// useEffect для отслеживания изменения ориентации и размеров экрана
useEffect(() => {
const handleResize = () => {
setWindowDimensions({
width: window.innerWidth,
height: window.innerHeight,
});
};
const handleOrientationChange = () => {
// Небольшая задержка для корректного получения новых размеров после поворота
setTimeout(() => {
setWindowDimensions({
width: window.innerWidth,
height: window.innerHeight,
});
}, 100);
};
// Слушаем событие resize (срабатывает при изменении размера окна)
window.addEventListener("resize", handleResize);
// Слушаем событие orientationchange (срабатывает при повороте устройства)
window.addEventListener("orientationchange", handleOrientationChange);
// Также слушаем изменения в screen.orientation API (современный способ)
if (screen.orientation) {
screen.orientation.addEventListener("change", handleOrientationChange);
}
return () => {
window.removeEventListener("resize", handleResize);
window.removeEventListener("orientationchange", handleOrientationChange);
if (screen.orientation) {
screen.orientation.removeEventListener(
"change",
handleOrientationChange
);
}
};
}, []);
// useEffect для throttle и отправки состояния speaking через socket
useEffect(() => {
// Отправляем только если состояние действительно изменилось
if (lastSentSpeakingRef.current === localSpeaking) {
return;
}
// Очищаем предыдущий таймер
if (speakingStateTimeoutRef.current) {
clearTimeout(speakingStateTimeoutRef.current);
}
// Отправляем состояние с задержкой (debounce 300ms)
speakingStateTimeoutRef.current = setTimeout(() => {
lastSentSpeakingRef.current = localSpeaking;
updateSpeakingState?.(localSpeaking);
speakingStateTimeoutRef.current = null;
}, 300);
return () => {
if (speakingStateTimeoutRef.current) {
clearTimeout(speakingStateTimeoutRef.current);
}
};
}, [localSpeaking, updateSpeakingState]);
// Вычисляем количество камер для grid
const activeCamerasCount =
(localStream ? LOCAL_CAMERAS_COUNT : 0) +
participants.filter(
(p) => p.stream != null && p.stream.getTracks().length > 0
).length;
// Определяем количество колонок в зависимости от количества камер
const getDesktopGridColumns = (count: number): number => {
if (count <= 2) return 1;
if (count <= 4) return 2;
if (count <= 9) return 3;
return 4;
};
const getMobilePortraitGridColumns = (count: number): number => {
if (count <= 3) return 1;
if (count <= 12) return 2;
return 3;
};
const getMobileLandscapeGridColumns = (count: number): number => {
if (count <= 3) return count;
if (count <= 6) return 3;
return 4;
};
const gridColumns =
windowDimensions.width >= 1440
? getDesktopGridColumns(activeCamerasCount)
: windowDimensions.height / windowDimensions.width < 1
? getMobileLandscapeGridColumns(activeCamerasCount)
: getMobilePortraitGridColumns(activeCamerasCount);
return (
<DraggableContainer
enableSnapping={mode === "mini"}
enabled={mode === "mini"}
autoAlign={true}
initialCorner={
windowDimensions.width >= 640 ? "bottom-right" : "top-right"
}
padding="1.111vw"
className={clsx(
"z-[100] 2xl:gap-[0.556vw] gap-2",
mode === "mini"
? "flex"
: `2xl:p-[2.778vw_5vw_5vw] p-[12px_12px_72px] w-full 2xl:h-dvh grid relative 2xl:bg-black auto-rows-fr`,
gridColumns === 1
? "grid-cols-1"
: gridColumns === 2
? "grid-cols-2"
: gridColumns === 3
? "grid-cols-3"
: "grid-cols-4"
)}
>
{mode === "full" && <div className="fixed bg-black inset-0 2xl:hidden" />}
{localStream &&
Array.from({ length: LOCAL_CAMERAS_COUNT }).map((_, index) => (
<UserCamera
key={index}
mode={mode}
name="Вы"
isMuted={isLocalAudioMuted}
isVideoOff={isLocalVideoMuted}
hasControl={localHasControl}
isAdmin={isLocalUserOrganizer}
isLocal={true}
mediaStream={localStream}
onSpeakingChange={handleSpeakingChange}
isLocalUserOrganizer={isLocalUserOrganizer}
participantId={currentUserId}
onGrantControl={grantControl}
onRevokeControl={revokeControl}
className={clsx(
mode === "full" &&
(activeCamerasCount <= 2
? "m-auto"
: "min-w-full w-full min-h-full")
)}
/>
))}
{/* Камеры удаленных участников - показываем только если есть поток с активными треками */}
{participants
.filter(
(participant) =>
participant.stream != null &&
participant.stream.getTracks().length > 0
)
.map((participant) => (
<UserCamera
className={clsx(
mode === "full" &&
(activeCamerasCount <= 2
? "m-auto"
: "min-w-full w-full min-h-full")
)}
key={participant.id}
mode={mode}
name={participant.name}
isMuted={participant.isMuted || false}
isVideoOff={participant.isVideoOff || false}
isSpeaking={participant.isSpeaking}
hasControl={participant.hasControl || false}
isAdmin={isParticipantOrganizer(participant.id) || undefined}
mediaStream={participant.stream}
hasLocalMediaPermission={hasLocalStream}
isLocalUserOrganizer={isLocalUserOrganizer}
participantId={participant.id}
onMuteParticipant={muteParticipant}
onDisableParticipantVideo={disableParticipantVideo}
onGrantControl={grantControl}
onRevokeControl={revokeControl}
/>
))}
<UserDevicesControls
mode={mode}
toggleAudio={toggleAudio}
toggleVideo={toggleVideo}
isAudioMuted={isLocalAudioMuted}
isVideoMuted={isLocalVideoMuted}
hasLocalStream={hasLocalStream}
hasControl={localHasControl}
onToggleControl={() => {
// Организатор всегда может вернуть управление себе
if (isLocalUserOrganizer) {
revokeControl();
}
}}
isOrganizer={isLocalUserOrganizer}
/>
</DraggableContainer>
);
}
export default SessionUsersPanel;