This commit is contained in:
2025-10-31 14:40:51 +05:00
6 changed files with 166 additions and 82 deletions
+4 -4
View File
@@ -1,4 +1,4 @@
VITE_API_URL=http://localhost:3000 # VITE_API_URL=http://localhost:3000
VITE_WEBRTC_URL=http://localhost:3001 # VITE_WEBRTC_URL=http://localhost:3001
# VITE_API_URL=https://stream.graff.estate/api VITE_API_URL=https://stream.graff.estate/api
# VITE_WEBRTC_URL=https://stream.graff.estate VITE_WEBRTC_URL=https://stream.graff.estate
+1 -1
View File
@@ -496,7 +496,7 @@ export default function DraggableContainer({
// Если компонент отключен, просто рендерим children без стилей и логики // Если компонент отключен, просто рендерим children без стилей и логики
if (!enabled) { if (!enabled) {
return <>{children}</>; return <div className={className}>{children}</div>;
} }
return ( return (
+143 -65
View File
@@ -5,6 +5,8 @@ import { useWebRTC } from "../hooks/useWebRTC";
import clsx from "clsx"; import clsx from "clsx";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
const LOCAL_CAMERAS_COUNT = 10;
interface SessionUsersPanelProps { interface SessionUsersPanelProps {
roomId: string; roomId: string;
autoJoin?: boolean; autoJoin?: boolean;
@@ -33,11 +35,59 @@ function SessionUsersPanel({
const lastSentSpeakingRef = useRef<boolean>(false); const lastSentSpeakingRef = useRef<boolean>(false);
const speakingStateTimeoutRef = useRef<NodeJS.Timeout | null>(null); const speakingStateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// State для отслеживания размеров и ориентации экрана
const [windowDimensions, setWindowDimensions] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
// Callback для получения изменений состояния speaking от UserCamera // Callback для получения изменений состояния speaking от UserCamera
const handleSpeakingChange = (isSpeaking: boolean) => { const handleSpeakingChange = (isSpeaking: boolean) => {
setLocalSpeaking(isSpeaking); 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 для throttle и отправки состояния speaking через socket
useEffect(() => { useEffect(() => {
// Отправляем только если состояние действительно изменилось // Отправляем только если состояние действительно изменилось
@@ -66,45 +116,80 @@ function SessionUsersPanel({
// Вычисляем количество камер для grid // Вычисляем количество камер для grid
const activeCamerasCount = const activeCamerasCount =
(localStream ? 8 : 0) + (localStream ? LOCAL_CAMERAS_COUNT : 0) +
participants.filter( participants.filter(
(p) => p.stream != null && p.stream.getTracks().length > 0 (p) => p.stream != null && p.stream.getTracks().length > 0
).length; ).length;
// Определяем количество колонок в зависимости от количества камер // Определяем количество колонок в зависимости от количества камер
// 1-2 камеры: 1 колонка (друг под другом), 3-4: 2 колонки, 5-9: 3 колонки, 10-16: 4 колонки, 17-25: 5 колонок const getDesktopGridColumns = (count: number): number => {
const getGridColumns = (count: number): number => {
if (count <= 2) return 1; if (count <= 2) return 1;
if (count <= 4) return 2; if (count <= 4) return 2;
if (count <= 9) return 3; if (count <= 9) return 3;
if (count <= 16) return 4; return 4;
return 5;
}; };
const gridColumns = getGridColumns(activeCamerasCount); const getMobilePortraitGridColumns = (count: number): number => {
if (count <= 3) return 1;
if (count <= 12) return 2;
return 3;
};
// Вычисляем количество рядов для правильного расчета высоты const getMobileLandscapeGridColumns = (count: number): number => {
const gridRows = Math.ceil(activeCamerasCount / gridColumns); if (count <= 3) return count;
if (count <= 6) return 3;
return 4;
};
// Рендерим камеры const gridColumns =
const camerasContent = ( windowDimensions.width >= 1440
<> ? getDesktopGridColumns(activeCamerasCount)
{/* Локальная камера пользователя - показываем только если есть разрешение */} : windowDimensions.height / windowDimensions.width < 1
{localStream && ( ? getMobileLandscapeGridColumns(activeCamerasCount)
<UserCamera : getMobilePortraitGridColumns(activeCamerasCount);
mode={mode}
name="Вы" return (
isMuted={isLocalAudioMuted} <DraggableContainer
isVideoOff={isLocalVideoMuted} enableSnapping={mode === "full"}
isControlDisabled={false} enabled={mode === "full"}
isAdmin={true} autoAlign={true}
isLocal={true} initialCorner={
mediaStream={localStream} windowDimensions.width >= 640 ? "bottom-right" : "top-right"
onMute={toggleAudio} }
onVideoOff={toggleVideo} padding="1.111vw"
onCanControl={() => console.log("Toggle control")} className={clsx(
onSpeakingChange={handleSpeakingChange} "z-[999] 2xl:gap-[0.556vw] gap-2",
/> mode === "full"
? "flex"
: `2xl:p-[5vw] p-4 w-full 2xl:h-dvh max-2xl:portrait:max-h-[calc(100dvh-17.778vw)] max-2xl:landscape:max-h-[calc(100dvh-8.75vw)] grid grid-cols-${gridColumns}`
)} )}
>
{localStream &&
Array.from({ length: LOCAL_CAMERAS_COUNT }).map((_, index) => (
<UserCamera
key={index}
mode={mode}
name="Вы"
isMuted={isLocalAudioMuted}
isVideoOff={isLocalVideoMuted}
isControlDisabled={false}
isAdmin={true}
isLocal={true}
mediaStream={localStream}
onMute={toggleAudio}
onVideoOff={toggleVideo}
onCanControl={() => console.log("Toggle control")}
onSpeakingChange={handleSpeakingChange}
className={clsx(
mode === "mini" &&
(activeCamerasCount <= 2
? "m-auto"
: activeCamerasCount > 12
? "!aspect-square w-full"
: "w-full")
)}
/>
))}
{/* Камеры удаленных участников - показываем только если есть поток с активными треками */} {/* Камеры удаленных участников - показываем только если есть поток с активными треками */}
{participants {participants
@@ -115,6 +200,14 @@ function SessionUsersPanel({
) )
.map((participant) => ( .map((participant) => (
<UserCamera <UserCamera
className={clsx(
mode === "mini" &&
(activeCamerasCount <= 2
? "m-auto"
: activeCamerasCount > 12
? "!aspect-square w-full"
: "w-full")
)}
key={participant.id} key={participant.id}
mode={mode} mode={mode}
name={participant.id} name={participant.id}
@@ -141,47 +234,32 @@ function SessionUsersPanel({
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 // Для режима mini используем flex-обертку для центрирования и внутри grid
return ( // return (
<div className="flex justify-center items-center w-full h-full z-[99]a"> // <div className="flex justify-center items-center w-full h-full z-[99]a">
<div // <div
className={clsx( // className={clsx(
"grid 2xl:gap-[0.556vw] gap-2", // "grid 2xl:gap-[0.556vw] gap-2",
gridColumns === 1 && "grid-cols-1 2xl:w-[45vw] w-[calc(50vw-1rem)]", // 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 === 2 && "grid-cols-2 2xl:w-[90vw] w-[calc(100vw-2rem)]",
gridColumns === 3 && "grid-cols-3 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 === 4 && "grid-cols-4 2xl:w-[90vw] w-[calc(100vw-2rem)]",
gridColumns === 5 && "grid-cols-5 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 === 1 && "auto-rows-[calc((86vh-1.111vw)/1)]",
gridRows === 2 && "auto-rows-[calc((86vh-1.667vw)/2)]", // gridRows === 2 && "auto-rows-[calc((86vh-1.667vw)/2)]",
gridRows === 3 && "auto-rows-[calc((86vh-2.222vw)/3)]", // gridRows === 3 && "auto-rows-[calc((86vh-2.222vw)/3)]",
gridRows === 4 && "auto-rows-[calc((86vh-2.778vw)/4)]", // gridRows === 4 && "auto-rows-[calc((86vh-2.778vw)/4)]",
gridRows === 5 && "auto-rows-[calc((86vh-3.333vw)/5)]" // gridRows === 5 && "auto-rows-[calc((86vh-3.333vw)/5)]"
)} // )}
> // >
{camerasContent} // {camerasContent}
</div> // </div>
</div> // </div>
); // );
} }
export default SessionUsersPanel; export default SessionUsersPanel;
+4 -2
View File
@@ -45,7 +45,9 @@ function ControlsPopover({ session }: ControlsPopoverProps) {
function handleClickOpenSharePopup() { function handleClickOpenSharePopup() {
setIsOpened(false); setIsOpened(false);
setPopup(<SharePopup link="https://estate.stream/ahdy12jdco1" />); setPopup(
<SharePopup link={`${window.location.origin}/sessions/${session?.id}`} />
);
} }
function handleClickOpenSettingsModal() { function handleClickOpenSettingsModal() {
@@ -54,7 +56,7 @@ function ControlsPopover({ session }: ControlsPopoverProps) {
} }
return ( return (
<div className="order-3 2xl:hidden" ref={ref}> <div className="order-3 2xl:hidden z-[9999]" ref={ref}>
<FloatingActionButton <FloatingActionButton
ref={buttonRef} ref={buttonRef}
className={clsx(isOpened && "!bg-[#7B60F3]")} className={clsx(isOpened && "!bg-[#7B60F3]")}
+10 -4
View File
@@ -75,9 +75,13 @@ export default function UserCamera({
// Отправляем изменения состояния для локального пользователя // Отправляем изменения состояния для локального пользователя
// Используем ref для отслеживания предыдущего состояния, чтобы избежать лишних вызовов // Используем ref для отслеживания предыдущего состояния, чтобы избежать лишних вызовов
const prevLocalSpeakingRef = useRef<boolean>(localSpeaking); const prevLocalSpeakingRef = useRef<boolean>(localSpeaking);
useEffect(() => { useEffect(() => {
if (isLocal && onSpeakingChange && prevLocalSpeakingRef.current !== localSpeaking) { if (
isLocal &&
onSpeakingChange &&
prevLocalSpeakingRef.current !== localSpeaking
) {
prevLocalSpeakingRef.current = localSpeaking; prevLocalSpeakingRef.current = localSpeaking;
onSpeakingChange(localSpeaking); onSpeakingChange(localSpeaking);
} }
@@ -276,7 +280,7 @@ export default function UserCamera({
className={clsx( className={clsx(
mode === "full" 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-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", : "aspect-video 2xl:rounded-[2.222vw] rounded-[32px] overflow-hidden group relative pointer-events-auto max-w-full h-full self-center object-cover",
isLocal && "order-last", isLocal && "order-last",
isVideoOff ? "bg-green-500" : "bg-yellow-500/10", isVideoOff ? "bg-green-500" : "bg-yellow-500/10",
className className
@@ -290,7 +294,9 @@ export default function UserCamera({
window.innerWidth >= 1536 ? "0.069vw" : "1px" window.innerWidth >= 1536 ? "0.069vw" : "1px"
} rgba(255, 255, 255, 0.3)`, } rgba(255, 255, 255, 0.3)`,
transition: transition:
"box-shadow 0.1s ease-out, width 0.3s, background-color 0.3s", mode === "full"
? "box-shadow 0.1s ease-out, width 0.3s, background-color 0.3s"
: undefined,
}} }}
onClick={handleVideoClick} onClick={handleVideoClick}
> >
+4 -6
View File
@@ -85,7 +85,6 @@ function SessionPage() {
const session = sessionData?.session; const session = sessionData?.session;
function handleChatOpen() { function handleChatOpen() {
console.log("handleChatOpen");
setPopup(<ChatPopup sessionId={id} />); setPopup(<ChatPopup sessionId={id} />);
} }
@@ -99,7 +98,7 @@ 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"); const [mode, setMode] = useState<"full" | "mini">("mini");
function toggleMode() { function toggleMode() {
setMode(mode === "full" ? "mini" : "full"); setMode(mode === "full" ? "mini" : "full");
@@ -161,9 +160,8 @@ function SessionPage() {
return ( return (
<div <div
className={clsx( className={clsx(
mode === "full" "overflow-hidden relative order-3 w-screen h-dvh bg-black touch-none max-2xl:flex max-2xl:portrait:items-center",
? "flex overflow-hidden relative order-3 w-screen h-dvh bg-black justify-center_items-center touch-none" mode === "full" && "flex"
: "2xl:px-[5vw] grid 2xl:gap-[0.556vw] gap-2 bg-black relative w-screen h-dvh overflow-hidden"
)} )}
> >
{/* Pixel Streaming - показывается только когда сессия активна */} {/* Pixel Streaming - показывается только когда сессия активна */}
@@ -206,7 +204,7 @@ function SessionPage() {
</div> </div>
)} )}
<ActionsSidebarWrapper className="z-[99]"> <ActionsSidebarWrapper className="z-[9999]">
<FloatingActionButton onClick={toggleMode}> <FloatingActionButton onClick={toggleMode}>
<div className="2xl:size-[1.111vw] size-4 text-white"> <div className="2xl:size-[1.111vw] size-4 text-white">
{mode === "mini" ? <FullscreenExitIcon /> : <FullscreenIcon />} {mode === "mini" ? <FullscreenExitIcon /> : <FullscreenIcon />}