Files
stream.graff.tech-new/client/src/components/ui/UserCamera.tsx
T
mikhail_lanskikh b6eeaafe42 Add UserCameraAvatar image and enhance SessionUsersPanel with session management features
- Introduced UserCameraAvatar.png for user camera display.
- Updated SessionUsersPanel to accept a session prop, allowing for identification of local user as session organizer.
- Implemented logic to determine if participants are organizers based on session data.
- Adjusted admin control logic in UserCamera and UserCameraControls for better role management.
- Improved logging and conditional rendering in ParticipantItem and UserCamera components for enhanced debugging and user experience.
2025-11-05 19:18:29 +05:00

476 lines
18 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import HandRaisedFilledIcon from "../icons/HandRaisedFilledIcon";
import MicrophoneFilledIcon from "../icons/MicrophoneFilledIcon";
import VideoOffFilledIcon from "../icons/VideoOffFilledIcon";
import VideoFilledIcon from "../icons/VideoFilledIcon";
import Admin from "../indicators/Admin";
import clsx from "clsx";
import VolumeIcon from "../icons/VolumeIcon";
import VolumeOffIcon from "../icons/VolumeOffIcon";
import { useVoiceActivity } from "../../hooks/useVoiceActivity";
import MicrophoneOffFilledIcon from "../icons/MicrophoneOffFilledIcon";
interface UserCameraControlsProps {
isMuted: boolean;
isVideoOff: boolean;
hasControl: boolean;
}
interface UserCameraProps {
isMuted: boolean;
isVideoOff: boolean;
hasControl?: boolean;
onMute: () => void;
onVideoOff: () => void;
onCanControl: () => void;
isAdmin?: boolean;
name?: string;
mediaStream?: MediaStream | null;
isLocal?: boolean;
isSpeaking?: boolean; // Для удаленных участников - получаем по Socket.IO
onSpeakingChange?: (isSpeaking: boolean) => void; // Для локального - отправляем изменения
hasLocalMediaPermission?: boolean; // Есть ли у локального пользователя разрешение на медиа
mode: "full" | "mini";
className?: string;
}
export default function UserCamera({
isMuted,
isVideoOff,
hasControl = false,
// onMute,
// onVideoOff,
// onCanControl,
isAdmin = false,
name = "Гость",
mediaStream = null,
isLocal = false,
isSpeaking: remoteSpeaking,
onSpeakingChange,
hasLocalMediaPermission = false,
mode = "full",
className,
}: UserCameraProps) {
const ref = useRef<HTMLVideoElement>(null);
// Для удаленных участников: если у локального пользователя есть разрешение на медиа - unmute, иначе mute (для autoplay)
const [isAudioMuted, setIsAudioMuted] = useState(!hasLocalMediaPermission);
// Обновляем состояние muted при изменении hasLocalMediaPermission
useEffect(() => {
if (!isLocal) {
// Для удаленных участников: если у локального нет разрешения - mute для autoplay
setIsAudioMuted(!hasLocalMediaPermission);
console.log(
`[UserCamera] ${name} audio muted state updated to ${!hasLocalMediaPermission} (hasLocalMediaPermission: ${hasLocalMediaPermission})`
);
}
}, [hasLocalMediaPermission, isLocal, name]);
// Явно обновляем атрибут muted у video элемента при изменении isAudioMuted
useEffect(() => {
if (ref.current && !isLocal) {
ref.current.muted = isAudioMuted;
console.log(
`[UserCamera] ${name} video element muted attribute set to ${isAudioMuted}`
);
}
}, [isAudioMuted, isLocal, name]);
// Детекция голосовой активности (только для локального пользователя)
const { isSpeaking: isVoiceActive } = useVoiceActivity(
isLocal ? mediaStream : null
);
// Для локального - используем локальную детекцию
// Для удаленных - используем полученное состояние через Socket.IO
const localSpeaking = !isMuted && isVoiceActive;
const isSpeaking = isLocal ? localSpeaking : remoteSpeaking || false;
// Отправляем изменения состояния для локального пользователя
// Используем ref для отслеживания предыдущего состояния, чтобы избежать лишних вызовов
const prevLocalSpeakingRef = useRef<boolean>(localSpeaking);
useEffect(() => {
if (
isLocal &&
onSpeakingChange &&
prevLocalSpeakingRef.current !== localSpeaking
) {
prevLocalSpeakingRef.current = localSpeaking;
onSpeakingChange(localSpeaking);
}
}, [isLocal, localSpeaking, onSpeakingChange]);
// Рамка либо горит на 100%, либо выключена
// isSpeaking уже учитывает threshold и debounce (1 сек)
const ringOpacity = isSpeaking ? 1 : 0;
// Логируем для отладки (отключено для снижения шума)
// useEffect(() => {
// console.log(
// `[${name}${
// isLocal ? " (local)" : ""
// }] isSpeaking: ${isSpeaking}, ringOpacity: ${ringOpacity.toFixed(
// 2
// )}, isMuted: ${isMuted}`
// );
// }, [isSpeaking, ringOpacity, name, isMuted, isLocal]);
useEffect(() => {
if (ref.current && mediaStream) {
console.log(
`[UserCamera] Setting srcObject for ${name}, isLocal: ${isLocal}, stream:`,
mediaStream
);
ref.current.srcObject = mediaStream;
// Принудительно запускаем воспроизведение
ref.current.play().catch((error) => {
console.error(`[UserCamera] Failed to play video for ${name}:`, error);
});
// Дополнительная попытка воспроизведения с задержкой для Firefox
if (!isLocal) {
// Попытка через 500ms
setTimeout(() => {
if (ref.current) {
console.log(
`[UserCamera] First retry for ${name}, paused: ${ref.current.paused}, readyState: ${ref.current.readyState}`
);
if (ref.current.paused) {
ref.current.play().catch((error) => {
console.error(
`[UserCamera] First retry play failed for ${name}:`,
error
);
});
}
}
}, 500);
// Попытка через 1 секунду
setTimeout(() => {
if (ref.current && ref.current.paused) {
console.log(`[UserCamera] Second retry for ${name} after timeout`);
ref.current.play().catch((error) => {
console.error(
`[UserCamera] Second retry play failed for ${name}:`,
error
);
});
}
}, 1000);
// Еще одна попытка через 3 секунды
setTimeout(() => {
if (ref.current) {
console.log(
`[UserCamera] Final retry for ${name}, paused: ${ref.current.paused}, readyState: ${ref.current.readyState}`
);
if (ref.current.paused) {
ref.current.play().catch((error) => {
console.error(
`[UserCamera] Final retry play failed for ${name}:`,
error
);
});
}
}
}, 3000);
}
} else if (ref.current && !mediaStream) {
console.log(`[UserCamera] Clearing srcObject for ${name}`);
ref.current.srcObject = null;
}
}, [mediaStream, name, isLocal]);
// Добавляем обработчики событий для отладки
useEffect(() => {
const videoElement = ref.current;
if (!videoElement) return;
const handleLoadStart = () => {
console.log(`[UserCamera] ${name} video loadstart`);
};
const handleLoadedData = () => {
console.log(`[UserCamera] ${name} video loadeddata`);
};
const handleCanPlay = () => {
console.log(`[UserCamera] ${name} video canplay`);
};
const handleLoadedMetadata = () => {
console.log(`[UserCamera] ${name} video loadedmetadata`);
};
const handleCanPlayThrough = () => {
console.log(`[UserCamera] ${name} video canplaythrough`);
};
const handlePlay = () => {
console.log(`[UserCamera] ${name} video play event`);
};
const handlePlaying = () => {
console.log(`[UserCamera] ${name} video playing event`);
};
const handleWaiting = () => {
console.log(
`[UserCamera] ${name} video waiting event, paused: ${videoElement.paused}, readyState: ${videoElement.readyState}`
);
};
const handleStalled = () => {
console.log(
`[UserCamera] ${name} video stalled event, paused: ${videoElement.paused}, readyState: ${videoElement.readyState}`
);
};
const handlePause = () => {
console.log(`[UserCamera] ${name} video pause event`);
};
const handleError = (e: Event) => {
console.error(`[UserCamera] ${name} video error:`, e);
};
videoElement.addEventListener("loadstart", handleLoadStart);
videoElement.addEventListener("loadeddata", handleLoadedData);
videoElement.addEventListener("loadedmetadata", handleLoadedMetadata);
videoElement.addEventListener("canplay", handleCanPlay);
videoElement.addEventListener("canplaythrough", handleCanPlayThrough);
videoElement.addEventListener("play", handlePlay);
videoElement.addEventListener("playing", handlePlaying);
videoElement.addEventListener("waiting", handleWaiting);
videoElement.addEventListener("stalled", handleStalled);
videoElement.addEventListener("pause", handlePause);
videoElement.addEventListener("error", handleError);
return () => {
videoElement.removeEventListener("loadstart", handleLoadStart);
videoElement.removeEventListener("loadeddata", handleLoadedData);
videoElement.removeEventListener("loadedmetadata", handleLoadedMetadata);
videoElement.removeEventListener("canplay", handleCanPlay);
videoElement.removeEventListener("canplaythrough", handleCanPlayThrough);
videoElement.removeEventListener("play", handlePlay);
videoElement.removeEventListener("playing", handlePlaying);
videoElement.removeEventListener("waiting", handleWaiting);
videoElement.removeEventListener("stalled", handleStalled);
videoElement.removeEventListener("pause", handlePause);
videoElement.removeEventListener("error", handleError);
};
}, [name]);
const toggleRemoteAudio = () => {
if (!isLocal) {
const newMutedState = !isAudioMuted;
setIsAudioMuted(newMutedState);
console.log(
`[UserCamera] ${name} audio ${newMutedState ? "muted" : "unmuted"}`
);
}
};
const handleVideoClick = () => {
if (!isLocal && ref.current) {
console.log(
`[UserCamera] User clicked on ${name} video, paused: ${ref.current.paused}, readyState: ${ref.current.readyState}, muted: ${ref.current.muted}`
);
if (ref.current.paused) {
ref.current.play().catch((error) => {
console.error(`[UserCamera] Click play failed for ${name}:`, error);
});
} else {
console.log(`[UserCamera] Video ${name} is already playing`);
}
}
};
return (
<div
className={clsx(
"select-none group relative pointer-events-auto",
mode === "mini"
? "aspect-square h-fit flex-shrink-0 2xl:hover:w-[10.833vw] 2xl:w-[6.944vw] sm:w-[15.625vw] w-[27.778vw]"
: "aspect-video max-w-full h-full",
isLocal && "order-last",
className
)}
style={{
// boxShadow: isSpeaking
// ? `0 4px 40px 0 rgba(15,16,17,0.1), 0 2px 2px 0 rgba(0,0,0,0.06), 0 0 0 ${
// window.innerWidth >= 1536 ? "0.139vw" : "2px"
// } rgba(34, 197, 94, ${ringOpacity})`
// : `0 4px 40px 0 rgba(15,16,17,0.1), 0 2px 2px 0 rgba(0,0,0,0.06), 0 0 0 ${
// window.innerWidth >= 1536 ? "0.069vw" : "1px"
// } rgba(255, 255, 255, 0.3)`,
transition:
mode === "mini"
? "box-shadow 0.1s ease-out, width 0.3s, background-color 0.3s"
: undefined,
}}
onClick={handleVideoClick}
>
<video
ref={ref}
className={clsx(
"object-cover size-full 2xl:rounded-[1.667vw] rounded-2xl",
isLocal && "scale-x-[-1]"
// (!mediaStream || isVideoOff) && ""
)}
autoPlay
muted={isLocal ? true : isAudioMuted}
playsInline
style={{
boxShadow: isSpeaking
? `0 4px 40px 0 rgba(15,16,17,0.1), 0 2px 2px 0 rgba(0,0,0,0.06), 0 0 0 ${
window.innerWidth >= 1440 ? "0.139vw" : "2px"
} rgba(123, 96, 243, ${ringOpacity})`
: `0 4px 40px 0 rgba(15,16,17,0.1), 0 2px 2px 0 rgba(0,0,0,0.06), 0 0 0 ${
window.innerWidth >= 1440 ? "0.069vw" : "1px"
} rgba(255, 255, 255, 0.3)`,
transition:
mode === "mini"
? "box-shadow 0.1s ease-out, width 0.3s, background-color 0.3s"
: "box-shadow 0.1s ease-out",
}}
onLoadedData={() => {
if (!isLocal && ref.current) {
console.log(
`[UserCamera] onLoadedData for ${name}, attempting play, readyState: ${ref.current.readyState}`
);
ref.current.play().catch((error) => {
console.error(
`[UserCamera] onLoadedData play failed for ${name}:`,
error
);
});
}
}}
onLoadedMetadata={() => {
if (!isLocal && ref.current) {
console.log(
`[UserCamera] onLoadedMetadata for ${name}, attempting play, readyState: ${ref.current.readyState}`
);
ref.current.play().catch((error) => {
console.error(
`[UserCamera] onLoadedMetadata play failed for ${name}:`,
error
);
});
}
}}
onCanPlay={() => {
if (!isLocal && ref.current) {
console.log(`[UserCamera] onCanPlay for ${name}, attempting play`);
ref.current.play().catch((error) => {
console.error(
`[UserCamera] onCanPlay play failed for ${name}:`,
error
);
});
}
}}
onPlaying={() => {
console.log(
`[UserCamera] onPlaying for ${name} - video is actually playing!`
);
}}
onClick={(e) => {
e.stopPropagation();
handleVideoClick();
}}
/>
{isAdmin && mode === "mini" && (
<Admin className="absolute top-0 right-0 z-10" />
)}
{/* Заглушка когда нет видео */}
{(!mediaStream || isVideoOff) && (
<div className="select-none pointer-events-none absolute inset-0 bg-[url(/img/UserCameraAvatar.png)] bg-cover bg-center 2xl:rounded-[1.667vw] rounded-2xl" />
)}
{mode === "mini" && (
<div className="absolute whitespace-nowrap transition-opacity duration-300 group-hover:opacity-100 opacity-0 text-white button-s top-[0.556vw] left-1/2 translate-x-[-50%] px-[0.556vw] py-[0.278vw] rounded-full bg-[#14141440] backdrop-blur-[4px]">
{name}
</div>
)}
{/* Кнопка управления звуком для удаленных участников */}
{!isLocal && mediaStream && !isVideoOff && (
<div
className="absolute top-[0.556vw] right-[0.556vw] opacity-0 group-hover:opacity-100 transition-opacity duration-300"
onMouseDown={(e) => e.stopPropagation()}
>
<button
onClick={toggleRemoteAudio}
className="2xl:size-[1.667vw] size-6 bg-[#14141426] backdrop-blur-[4px] hover:bg-[#14141440] rounded-full flex items-center justify-center transition-colors"
title={isAudioMuted ? "Включить звук" : "Выключить звук"}
>
<div className="2xl:size-[0.972vw] size-3.5 text-white">
{isAudioMuted ? <VolumeOffIcon /> : <VolumeIcon />}
</div>
</button>
</div>
)}
{mode === "full" && (
<div className="2xl:px-[1.111vw] 2xl:py-[0.556vw] px-4 py-2 bg-[#141414]/25 backdrop-blur-[10px] 2xl:rounded-[1.111vw] rounded-2xl absolute 2xl:bottom-[1.111vw] bottom-4 left-1/2 -translate-x-1/2 z-10 flex 2xl:gap-[0.556vw] gap-2 items-center">
<p className="font-medium text-white button-m">{name}</p>
{isAdmin && (
<Admin className="2xl:size-[1.111vw] size-4 absolute 2xl:-top-[0.278vw] 2xl:-right-[0.278vw] -right-1 -top-1" />
)}
</div>
)}
{/* Индикаторы состояния только для удаленных участников */}
{!isLocal && mode === "mini" && (
<UserCameraControls
isMuted={isMuted}
isVideoOff={isVideoOff}
hasControl={hasControl}
/>
)}
</div>
);
}
function UserCameraControls({
isMuted,
isVideoOff,
hasControl,
}: UserCameraControlsProps) {
return (
<div
className="select-none absolute transition-opacity 2xl:bottom-[0.278vw] bottom-1 left-1/2 -translate-x-1/2 flex 2xl:gap-[0.278vw] gap-1 2xl:mb-[0.278vw] mb-1 group-hover:opacity-100 opacity-0"
// onMouseDown={(e) => e.stopPropagation()}
>
{/* Индикатор микрофона */}
<div className="2xl:size-[1.667vw] size-6 bg-[#14141426] backdrop-blur-[4px] rounded-full flex items-center justify-center">
<div className="2xl:size-[0.972vw] size-3.5 text-white">
{isMuted ? <MicrophoneOffFilledIcon /> : <MicrophoneFilledIcon />}
</div>
</div>
{/* Индикатор видео */}
<div className="2xl:size-[1.667vw] size-6 bg-[#14141426] backdrop-blur-[4px] rounded-full flex items-center justify-center">
<div className="2xl:size-[0.972vw] size-3.5 text-white">
{isVideoOff ? <VideoOffFilledIcon /> : <VideoFilledIcon />}
</div>
</div>
{/* Индикатор управления - показывается только если hasControl = true */}
{hasControl && (
<div className="2xl:size-[1.667vw] size-6 bg-[#14141426] backdrop-blur-[4px] rounded-full flex items-center justify-center">
<div className="2xl:size-[0.972vw] size-3.5 text-white">
<HandRaisedFilledIcon />
</div>
</div>
)}
</div>
);
}