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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user