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(false); const lastSentSpeakingRef = useRef(false); const speakingStateTimeoutRef = useRef(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 ( = 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" &&
} {localStream && Array.from({ length: LOCAL_CAMERAS_COUNT }).map((_, index) => ( ))} {/* Камеры удаленных участников - показываем только если есть поток с активными треками */} {participants .filter( (participant) => participant.stream != null && participant.stream.getTracks().length > 0 ) .map((participant) => ( ))} { // Организатор всегда может вернуть управление себе if (isLocalUserOrganizer) { revokeControl(); } }} isOrganizer={isLocalUserOrganizer} /> ); } export default SessionUsersPanel;