diff --git a/client/src/components/SessionUsersPanel.tsx b/client/src/components/SessionUsersPanel.tsx
index 97f5542..47313a1 100644
--- a/client/src/components/SessionUsersPanel.tsx
+++ b/client/src/components/SessionUsersPanel.tsx
@@ -2,16 +2,18 @@ import UserCamera from "./ui/UserCamera";
import UserDevicesControls from "./ui/UserDevicesControls";
import DraggableContainer from "./DraggableContainer";
import { useWebRTC } from "../hooks/useWebRTC";
-import { useCallback } from "react";
+import clsx from "clsx";
interface SessionUsersPanelProps {
roomId: string;
autoJoin?: boolean;
+ mode?: "full" | "mini";
}
function SessionUsersPanel({
roomId,
autoJoin = false,
+ mode = "full",
}: SessionUsersPanelProps) {
const {
localStream,
@@ -26,21 +28,38 @@ function SessionUsersPanel({
const hasLocalStream = localStream !== null;
// Callback для отправки состояния speaking
- const handleSpeakingChange = useCallback((isSpeaking: boolean) => {
+ const handleSpeakingChange = (isSpeaking: boolean) => {
updateSpeakingState?.(isSpeaking);
- }, [updateSpeakingState]);
+ };
- return (
- = 640 ? "bottom-right" : "top-right"}
- padding="1.111vw"
- className="flex gap-4"
- >
+ // Вычисляем количество камер для grid
+ const activeCamerasCount =
+ (localStream ? 8 : 0) +
+ participants.filter(
+ (p) => p.stream != null && p.stream.getTracks().length > 0
+ ).length;
+ // Определяем количество колонок в зависимости от количества камер
+ // 1-2 камеры: 1 колонка (друг под другом), 3-4: 2 колонки, 5-9: 3 колонки, 10-16: 4 колонки, 17-25: 5 колонок
+ const getGridColumns = (count: number): number => {
+ if (count <= 2) return 1;
+ if (count <= 4) return 2;
+ if (count <= 9) return 3;
+ if (count <= 16) return 4;
+ return 5;
+ };
+
+ const gridColumns = getGridColumns(activeCamerasCount);
+
+ // Вычисляем количество рядов для правильного расчета высоты
+ const gridRows = Math.ceil(activeCamerasCount / gridColumns);
+
+ // Рендерим камеры
+ const camerasContent = (
+ <>
{/* Локальная камера пользователя - показываем только если есть разрешение */}
{localStream && (
(
console.log(`Mute user ${participant.id}`)}
@@ -82,13 +102,53 @@ function SessionUsersPanel({
))}
-
+ >
+ );
+
+ // Для режима full используем DraggableContainer
+ if (mode === "full") {
+ return (
+ = 640 ? "bottom-right" : "top-right"}
+ padding="1.111vw"
+ className="z-[999] flex gap-4"
+ >
+ {camerasContent}
+
+ );
+ }
+
+ // Для режима mini используем flex-обертку для центрирования и внутри grid
+ return (
+
);
}
diff --git a/client/src/components/ui/UserCamera.tsx b/client/src/components/ui/UserCamera.tsx
index 1672c1f..264b118 100644
--- a/client/src/components/ui/UserCamera.tsx
+++ b/client/src/components/ui/UserCamera.tsx
@@ -37,6 +37,8 @@ interface UserCameraProps {
isSpeaking?: boolean; // Для удаленных участников - получаем по Socket.IO
onSpeakingChange?: (isSpeaking: boolean) => void; // Для локального - отправляем изменения
hasLocalMediaPermission?: boolean; // Есть ли у локального пользователя разрешение на медиа
+ mode: "full" | "mini";
+ className?: string;
}
export default function UserCamera({
@@ -53,6 +55,8 @@ export default function UserCamera({
isSpeaking: remoteSpeaking,
onSpeakingChange,
hasLocalMediaPermission = false,
+ mode = "full",
+ className,
}: UserCameraProps) {
const ref = useRef(null);
// Для удаленных участников: если у локального пользователя есть разрешение на медиа - unmute, иначе mute (для autoplay)
@@ -243,9 +247,7 @@ export default function UserCamera({
const newMutedState = !isAudioMuted;
setIsAudioMuted(newMutedState);
console.log(
- `[UserCamera] ${name} audio ${
- newMutedState ? "muted" : "unmuted"
- }`
+ `[UserCamera] ${name} audio ${newMutedState ? "muted" : "unmuted"}`
);
}
};
@@ -268,9 +270,12 @@ export default function UserCamera({
return (
void;
@@ -14,6 +15,7 @@ export interface UserDevicesControlsProps {
isAudioMuted: boolean;
isVideoMuted: boolean;
hasLocalStream?: boolean;
+ mode?: "full" | "mini";
}
export default function UserDevicesControls({
@@ -22,6 +24,7 @@ export default function UserDevicesControls({
isAudioMuted,
isVideoMuted,
hasLocalStream = true,
+ mode = "full",
}: UserDevicesControlsProps) {
const { setModal } = useModalStore();
@@ -30,7 +33,13 @@ export default function UserDevicesControls({
}
return (
-
+
e.stopPropagation()}
size="large"
diff --git a/client/src/pages/SessionPage.tsx b/client/src/pages/SessionPage.tsx
index 27e75e4..40cd59a 100644
--- a/client/src/pages/SessionPage.tsx
+++ b/client/src/pages/SessionPage.tsx
@@ -26,6 +26,7 @@ import SessionUsersPanel from "../components/SessionUsersPanel";
import { useWebRTC } from "../hooks/useWebRTC";
import MicrophoneOffFilledIcon from "../components/icons/MicrophoneOffFilledIcon";
import VideoFilledIcon from "../components/icons/VideoFilledIcon";
+import clsx from "clsx";
function SessionPage() {
const { setPopup } = usePopupStore();
@@ -93,6 +94,12 @@ function SessionPage() {
setPopup();
}
+ const [mode, setMode] = useState<"full" | "mini">("full");
+
+ function toggleMode() {
+ setMode(mode === "full" ? "mini" : "full");
+ }
+
// Не перенаправляем автоматически - пользователи могут продолжать общаться
// useEffect(() => {
// if (session?.status === "ended") {
@@ -147,13 +154,19 @@ function SessionPage() {
}
return (
-
+
{/* Pixel Streaming - показывается только когда сессия активна */}
{session.status === "started" &&
session.mode === "stream" &&
session.server?.localIp &&
session.playerPort && (
-
+
+
+
+ {mode === "mini" ? : }
+
+
{/* WebRTC видеочат - работает всегда, пока пользователь на странице */}
-
+
);
}