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 UserDevicesControls from "./ui/UserDevicesControls";
|
||||||
import DraggableContainer from "./DraggableContainer";
|
import DraggableContainer from "./DraggableContainer";
|
||||||
import { useWebRTC } from "../hooks/useWebRTC";
|
import { useWebRTC } from "../hooks/useWebRTC";
|
||||||
import { useCallback } from "react";
|
import clsx from "clsx";
|
||||||
|
|
||||||
interface SessionUsersPanelProps {
|
interface SessionUsersPanelProps {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
autoJoin?: boolean;
|
autoJoin?: boolean;
|
||||||
|
mode?: "full" | "mini";
|
||||||
}
|
}
|
||||||
|
|
||||||
function SessionUsersPanel({
|
function SessionUsersPanel({
|
||||||
roomId,
|
roomId,
|
||||||
autoJoin = false,
|
autoJoin = false,
|
||||||
|
mode = "full",
|
||||||
}: SessionUsersPanelProps) {
|
}: SessionUsersPanelProps) {
|
||||||
const {
|
const {
|
||||||
localStream,
|
localStream,
|
||||||
@@ -26,21 +28,38 @@ function SessionUsersPanel({
|
|||||||
const hasLocalStream = localStream !== null;
|
const hasLocalStream = localStream !== null;
|
||||||
|
|
||||||
// Callback для отправки состояния speaking
|
// Callback для отправки состояния speaking
|
||||||
const handleSpeakingChange = useCallback((isSpeaking: boolean) => {
|
const handleSpeakingChange = (isSpeaking: boolean) => {
|
||||||
updateSpeakingState?.(isSpeaking);
|
updateSpeakingState?.(isSpeaking);
|
||||||
}, [updateSpeakingState]);
|
};
|
||||||
|
|
||||||
return (
|
// Вычисляем количество камер для grid
|
||||||
<DraggableContainer
|
const activeCamerasCount =
|
||||||
enableSnapping={true}
|
(localStream ? 8 : 0) +
|
||||||
autoAlign={true}
|
participants.filter(
|
||||||
initialCorner={innerWidth >= 640 ? "bottom-right" : "top-right"}
|
(p) => p.stream != null && p.stream.getTracks().length > 0
|
||||||
padding="1.111vw"
|
).length;
|
||||||
className="flex gap-4 z-[999]"
|
// Определяем количество колонок в зависимости от количества камер
|
||||||
>
|
// 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 && (
|
{localStream && (
|
||||||
<UserCamera
|
<UserCamera
|
||||||
|
mode={mode}
|
||||||
name="Вы"
|
name="Вы"
|
||||||
isMuted={isLocalAudioMuted}
|
isMuted={isLocalAudioMuted}
|
||||||
isVideoOff={isLocalVideoMuted}
|
isVideoOff={isLocalVideoMuted}
|
||||||
@@ -65,12 +84,13 @@ function SessionUsersPanel({
|
|||||||
.map((participant) => (
|
.map((participant) => (
|
||||||
<UserCamera
|
<UserCamera
|
||||||
key={participant.id}
|
key={participant.id}
|
||||||
|
mode={mode}
|
||||||
name={participant.id}
|
name={participant.id}
|
||||||
isMuted={participant.isMuted || false}
|
isMuted={participant.isMuted || false}
|
||||||
isVideoOff={participant.isVideoOff || false}
|
isVideoOff={participant.isVideoOff || false}
|
||||||
isSpeaking={participant.isSpeaking}
|
isSpeaking={participant.isSpeaking}
|
||||||
isControlDisabled={true}
|
isControlDisabled={true}
|
||||||
isAdmin={true} // Локальный пользователь - админ своей сессии
|
isAdmin={true}
|
||||||
mediaStream={participant.stream}
|
mediaStream={participant.stream}
|
||||||
hasLocalMediaPermission={hasLocalStream}
|
hasLocalMediaPermission={hasLocalStream}
|
||||||
onMute={() => console.log(`Mute user ${participant.id}`)}
|
onMute={() => console.log(`Mute user ${participant.id}`)}
|
||||||
@@ -82,13 +102,53 @@ function SessionUsersPanel({
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
<UserDevicesControls
|
<UserDevicesControls
|
||||||
|
mode={mode}
|
||||||
toggleAudio={toggleAudio}
|
toggleAudio={toggleAudio}
|
||||||
toggleVideo={toggleVideo}
|
toggleVideo={toggleVideo}
|
||||||
isAudioMuted={isLocalAudioMuted}
|
isAudioMuted={isLocalAudioMuted}
|
||||||
isVideoMuted={isLocalVideoMuted}
|
isVideoMuted={isLocalVideoMuted}
|
||||||
hasLocalStream={hasLocalStream}
|
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
|
isSpeaking?: boolean; // Для удаленных участников - получаем по Socket.IO
|
||||||
onSpeakingChange?: (isSpeaking: boolean) => void; // Для локального - отправляем изменения
|
onSpeakingChange?: (isSpeaking: boolean) => void; // Для локального - отправляем изменения
|
||||||
hasLocalMediaPermission?: boolean; // Есть ли у локального пользователя разрешение на медиа
|
hasLocalMediaPermission?: boolean; // Есть ли у локального пользователя разрешение на медиа
|
||||||
|
mode: "full" | "mini";
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UserCamera({
|
export default function UserCamera({
|
||||||
@@ -53,6 +55,8 @@ export default function UserCamera({
|
|||||||
isSpeaking: remoteSpeaking,
|
isSpeaking: remoteSpeaking,
|
||||||
onSpeakingChange,
|
onSpeakingChange,
|
||||||
hasLocalMediaPermission = false,
|
hasLocalMediaPermission = false,
|
||||||
|
mode = "full",
|
||||||
|
className,
|
||||||
}: UserCameraProps) {
|
}: UserCameraProps) {
|
||||||
const ref = useRef<HTMLVideoElement>(null);
|
const ref = useRef<HTMLVideoElement>(null);
|
||||||
// Для удаленных участников: если у локального пользователя есть разрешение на медиа - unmute, иначе mute (для autoplay)
|
// Для удаленных участников: если у локального пользователя есть разрешение на медиа - unmute, иначе mute (для autoplay)
|
||||||
@@ -243,9 +247,7 @@ export default function UserCamera({
|
|||||||
const newMutedState = !isAudioMuted;
|
const newMutedState = !isAudioMuted;
|
||||||
setIsAudioMuted(newMutedState);
|
setIsAudioMuted(newMutedState);
|
||||||
console.log(
|
console.log(
|
||||||
`[UserCamera] ${name} audio ${
|
`[UserCamera] ${name} audio ${newMutedState ? "muted" : "unmuted"}`
|
||||||
newMutedState ? "muted" : "unmuted"
|
|
||||||
}`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -268,9 +270,12 @@ export default function UserCamera({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
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",
|
isLocal && "order-last",
|
||||||
isVideoOff ? "bg-green-500" : "bg-yellow-500/10"
|
isVideoOff ? "bg-green-500" : "bg-yellow-500/10",
|
||||||
|
className
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
boxShadow: isSpeaking
|
boxShadow: isSpeaking
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import CogFilledIcon from "../icons/CogFilledIcon";
|
|||||||
import useModalStore from "../../store/modalStore";
|
import useModalStore from "../../store/modalStore";
|
||||||
import SettingsModal from "../modals/SettingsModal";
|
import SettingsModal from "../modals/SettingsModal";
|
||||||
import MicrophoneOffFilledIcon from "../icons/MicrophoneOffFilledIcon";
|
import MicrophoneOffFilledIcon from "../icons/MicrophoneOffFilledIcon";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
export interface UserDevicesControlsProps {
|
export interface UserDevicesControlsProps {
|
||||||
toggleAudio: () => void;
|
toggleAudio: () => void;
|
||||||
@@ -14,6 +15,7 @@ export interface UserDevicesControlsProps {
|
|||||||
isAudioMuted: boolean;
|
isAudioMuted: boolean;
|
||||||
isVideoMuted: boolean;
|
isVideoMuted: boolean;
|
||||||
hasLocalStream?: boolean;
|
hasLocalStream?: boolean;
|
||||||
|
mode?: "full" | "mini";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UserDevicesControls({
|
export default function UserDevicesControls({
|
||||||
@@ -22,6 +24,7 @@ export default function UserDevicesControls({
|
|||||||
isAudioMuted,
|
isAudioMuted,
|
||||||
isVideoMuted,
|
isVideoMuted,
|
||||||
hasLocalStream = true,
|
hasLocalStream = true,
|
||||||
|
mode = "full",
|
||||||
}: UserDevicesControlsProps) {
|
}: UserDevicesControlsProps) {
|
||||||
const { setModal } = useModalStore();
|
const { setModal } = useModalStore();
|
||||||
|
|
||||||
@@ -30,7 +33,13 @@ export default function UserDevicesControls({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<ControlButton
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
size="large"
|
size="large"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import SessionUsersPanel from "../components/SessionUsersPanel";
|
|||||||
import { useWebRTC } from "../hooks/useWebRTC";
|
import { useWebRTC } from "../hooks/useWebRTC";
|
||||||
import MicrophoneOffFilledIcon from "../components/icons/MicrophoneOffFilledIcon";
|
import MicrophoneOffFilledIcon from "../components/icons/MicrophoneOffFilledIcon";
|
||||||
import VideoFilledIcon from "../components/icons/VideoFilledIcon";
|
import VideoFilledIcon from "../components/icons/VideoFilledIcon";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
function SessionPage() {
|
function SessionPage() {
|
||||||
const { setPopup } = usePopupStore();
|
const { setPopup } = usePopupStore();
|
||||||
@@ -93,6 +94,12 @@ function SessionPage() {
|
|||||||
setPopup(<SharePopup link={`${window.location.origin}/sessions/${id}`} />);
|
setPopup(<SharePopup link={`${window.location.origin}/sessions/${id}`} />);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [mode, setMode] = useState<"full" | "mini">("full");
|
||||||
|
|
||||||
|
function toggleMode() {
|
||||||
|
setMode(mode === "full" ? "mini" : "full");
|
||||||
|
}
|
||||||
|
|
||||||
// Не перенаправляем автоматически - пользователи могут продолжать общаться
|
// Не перенаправляем автоматически - пользователи могут продолжать общаться
|
||||||
// useEffect(() => {
|
// useEffect(() => {
|
||||||
// if (session?.status === "ended") {
|
// if (session?.status === "ended") {
|
||||||
@@ -147,13 +154,19 @@ function SessionPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 - показывается только когда сессия активна */}
|
{/* Pixel Streaming - показывается только когда сессия активна */}
|
||||||
{session.status === "started" &&
|
{session.status === "started" &&
|
||||||
session.mode === "stream" &&
|
session.mode === "stream" &&
|
||||||
session.server?.localIp &&
|
session.server?.localIp &&
|
||||||
session.playerPort && (
|
session.playerPort && (
|
||||||
<div className="w-full h-full aspect-video">
|
<div className=" absolute w-full h-full aspect-video">
|
||||||
<PixelStreamingWrapper
|
<PixelStreamingWrapper
|
||||||
initialSettings={{
|
initialSettings={{
|
||||||
ss: `ws://${session.server.localIp}:${session.playerPort}`,
|
ss: `ws://${session.server.localIp}:${session.playerPort}`,
|
||||||
@@ -189,6 +202,11 @@ function SessionPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ActionsSidebarWrapper className="z-[99]">
|
<ActionsSidebarWrapper className="z-[99]">
|
||||||
|
<FloatingActionButton onClick={toggleMode}>
|
||||||
|
<div className="2xl:size-[1.111vw] size-4 text-white">
|
||||||
|
{mode === "mini" ? <FullscreenExitIcon /> : <FullscreenIcon />}
|
||||||
|
</div>
|
||||||
|
</FloatingActionButton>
|
||||||
<FloatingActionButton
|
<FloatingActionButton
|
||||||
className="max-2xl:hidden"
|
className="max-2xl:hidden"
|
||||||
onClick={handleChatOpen}
|
onClick={handleChatOpen}
|
||||||
@@ -256,7 +274,7 @@ function SessionPage() {
|
|||||||
</ActionsSidebarWrapper>
|
</ActionsSidebarWrapper>
|
||||||
|
|
||||||
{/* WebRTC видеочат - работает всегда, пока пользователь на странице */}
|
{/* WebRTC видеочат - работает всегда, пока пользователь на странице */}
|
||||||
<SessionUsersPanel roomId={session.id} autoJoin={true} />
|
<SessionUsersPanel roomId={session.id} autoJoin={true} mode={mode} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user