Enhance SessionUsersPanel and UserCamera components to support 'full' and 'mini' modes. Implement responsive grid layout for camera display based on active participants, and adjust UserDevicesControls visibility accordingly. Refactor SessionPage to manage mode state and toggle between layouts.

This commit is contained in:
2025-10-30 18:01:09 +05:00
parent e90cc37a9a
commit 86ca74d79c
4 changed files with 114 additions and 22 deletions
+73 -13
View File
@@ -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 (
<DraggableContainer
enableSnapping={true}
autoAlign={true}
initialCorner={innerWidth >= 640 ? "bottom-right" : "top-right"}
padding="1.111vw"
className="flex gap-4 z-[999]"
>
// Вычисляем количество камер для 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 && (
<UserCamera
mode={mode}
name="Вы"
isMuted={isLocalAudioMuted}
isVideoOff={isLocalVideoMuted}
@@ -65,12 +84,13 @@ function SessionUsersPanel({
.map((participant) => (
<UserCamera
key={participant.id}
mode={mode}
name={participant.id}
isMuted={participant.isMuted || false}
isVideoOff={participant.isVideoOff || false}
isSpeaking={participant.isSpeaking}
isControlDisabled={true}
isAdmin={true} // Локальный пользователь - админ своей сессии
isAdmin={true}
mediaStream={participant.stream}
hasLocalMediaPermission={hasLocalStream}
onMute={() => console.log(`Mute user ${participant.id}`)}
@@ -82,13 +102,53 @@ function SessionUsersPanel({
))}
<UserDevicesControls
mode={mode}
toggleAudio={toggleAudio}
toggleVideo={toggleVideo}
isAudioMuted={isLocalAudioMuted}
isVideoMuted={isLocalVideoMuted}
hasLocalStream={hasLocalStream}
/>
</DraggableContainer>
</>
);
// Для режима full используем DraggableContainer
if (mode === "full") {
return (
<DraggableContainer
enableSnapping={true}
enabled={true}
autoAlign={true}
initialCorner={innerWidth >= 640 ? "bottom-right" : "top-right"}
padding="1.111vw"
className="z-[999] flex gap-4"
>
{camerasContent}
</DraggableContainer>
);
}
// Для режима mini используем flex-обертку для центрирования и внутри grid
return (
<div className="flex justify-center items-center w-full h-full z-[99]a">
<div
className={clsx(
"grid 2xl:gap-[0.556vw] gap-2",
gridColumns === 1 && "grid-cols-1 2xl:w-[45vw] w-[calc(50vw-1rem)]",
gridColumns === 2 && "grid-cols-2 2xl:w-[90vw] w-[calc(100vw-2rem)]",
gridColumns === 3 && "grid-cols-3 2xl:w-[90vw] w-[calc(100vw-2rem)]",
gridColumns === 4 && "grid-cols-4 2xl:w-[90vw] w-[calc(100vw-2rem)]",
gridColumns === 5 && "grid-cols-5 2xl:w-[90vw] w-[calc(100vw-2rem)]",
gridRows === 1 && "auto-rows-[calc((86vh-1.111vw)/1)]",
gridRows === 2 && "auto-rows-[calc((86vh-1.667vw)/2)]",
gridRows === 3 && "auto-rows-[calc((86vh-2.222vw)/3)]",
gridRows === 4 && "auto-rows-[calc((86vh-2.778vw)/4)]",
gridRows === 5 && "auto-rows-[calc((86vh-3.333vw)/5)]"
)}
>
{camerasContent}
</div>
</div>
);
}
+10 -5
View File
@@ -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<HTMLVideoElement>(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 (
<div
className={clsx(
"aspect-square h-fit group 2xl:rounded-[1.667vw] rounded-2xl relative flex-shrink-0 pointer-events-auto 2xl:hover:w-[10.833vw] 2xl:w-[6.944vw] sm:w-[15.625vw] w-[27.778vw] overflow-hidden",
mode === "full"
? "aspect-square h-fit group 2xl:rounded-[1.667vw] rounded-2xl relative flex-shrink-0 pointer-events-auto 2xl:hover:w-[10.833vw] 2xl:w-[6.944vw] sm:w-[15.625vw] w-[27.778vw] overflow-hidden"
: "aspect-video 2xl:rounded-[2.222vw] rounded-[32px] overflow-hidden group relative flex-shrink-0 pointer-events-auto w-full h-full object-contain",
isLocal && "order-last",
isVideoOff ? "bg-green-500" : "bg-yellow-500/10"
isVideoOff ? "bg-green-500" : "bg-yellow-500/10",
className
)}
style={{
boxShadow: isSpeaking
@@ -7,6 +7,7 @@ import CogFilledIcon from "../icons/CogFilledIcon";
import useModalStore from "../../store/modalStore";
import SettingsModal from "../modals/SettingsModal";
import MicrophoneOffFilledIcon from "../icons/MicrophoneOffFilledIcon";
import clsx from "clsx";
export interface UserDevicesControlsProps {
toggleAudio: () => 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 (
<div className="hidden order-last 2xl:grid grid-cols-2 gap-[0.278vw] aspect-square p-[0.556vw] flex-wrap justify-between items-center size-[6.944vw] rounded-[1.667vw] bg-[#00000040] shadow-[0_4px_40px_0_#0F10111A] backdrop-blur-[10px] pointer-events-auto">
<div
className={clsx(
mode === "full"
? "hidden order-last 2xl:grid grid-cols-2 gap-[0.278vw] aspect-square p-[0.556vw] flex-wrap justify-between items-center size-[6.944vw] rounded-[1.667vw] bg-[#00000040] shadow-[0_4px_40px_0_#0F10111A] backdrop-blur-[10px] pointer-events-auto"
: "2xl:flex hidden items-center gap-[0.278vw] p-[0.556vw] rounded-[1.667vw] ring-[0.104vw] ring-[#FFFFFF]/15 absolute bottom-[0.556vw] left-1/2 -translate-x-1/2 bg-[#00000040] backdrop-blur-[10px]"
)}
>
<ControlButton
onMouseDown={(e) => e.stopPropagation()}
size="large"
+21 -3
View File
@@ -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(<SharePopup link={`${window.location.origin}/sessions/${id}`} />);
}
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 (
<div className="flex overflow-hidden relative order-3 w-screen h-dvh bg-black justify-center_items-center touch-none">
<div
className={clsx(
mode === "full"
? "flex overflow-hidden relative order-3 w-screen h-dvh bg-black justify-center_items-center touch-none"
: "2xl:px-[5vw] grid 2xl:gap-[0.556vw] gap-2 bg-black relative w-screen h-dvh overflow-hidden"
)}
>
{/* Pixel Streaming - показывается только когда сессия активна */}
{session.status === "started" &&
session.mode === "stream" &&
session.server?.localIp &&
session.playerPort && (
<div className="w-full h-full aspect-video">
<div className=" absolute w-full h-full aspect-video">
<PixelStreamingWrapper
initialSettings={{
ss: `ws://${session.server.localIp}:${session.playerPort}`,
@@ -189,6 +202,11 @@ function SessionPage() {
)}
<ActionsSidebarWrapper className="z-[99]">
<FloatingActionButton onClick={toggleMode}>
<div className="2xl:size-[1.111vw] size-4 text-white">
{mode === "mini" ? <FullscreenExitIcon /> : <FullscreenIcon />}
</div>
</FloatingActionButton>
<FloatingActionButton
className="max-2xl:hidden"
onClick={handleChatOpen}
@@ -256,7 +274,7 @@ function SessionPage() {
</ActionsSidebarWrapper>
{/* WebRTC видеочат - работает всегда, пока пользователь на странице */}
<SessionUsersPanel roomId={session.id} autoJoin={true} />
<SessionUsersPanel roomId={session.id} autoJoin={true} mode={mode} />
</div>
);
}