b6eeaafe42
- 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.
476 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|