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.
This commit is contained in:
2025-11-05 19:18:29 +05:00
parent d5b17d60c9
commit b6eeaafe42
5 changed files with 101 additions and 117 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 670 KiB

+25 -4
View File
@@ -4,6 +4,9 @@ import DraggableContainer from "./DraggableContainer";
import { useWebRTC } from "../hooks/useWebRTC";
import clsx from "clsx";
import { useEffect, useRef, useState } from "react";
import type { Session } from "../types/Session";
import { getGuestId } from "../lib/guestId";
import { useMe } from "../hooks/useAuth";
const LOCAL_CAMERAS_COUNT = 1;
@@ -11,12 +14,14 @@ interface SessionUsersPanelProps {
roomId: string;
autoJoin?: boolean;
mode?: "full" | "mini";
session?: Session;
}
function SessionUsersPanel({
roomId,
autoJoin = false,
mode = "full",
session,
}: SessionUsersPanelProps) {
const {
localStream,
@@ -29,6 +34,22 @@ function SessionUsersPanel({
} = useWebRTC(roomId, autoJoin);
const hasLocalStream = localStream !== null;
const { data: user } = useMe();
// Определяем, является ли локальный пользователь организатором сессии
const isLocalUserOrganizer = session
? !!(session.userId && user?.id === session.userId) ||
!!(session.guestId && getGuestId() === session.guestId)
: false;
// Функция для определения, является ли конкретный участник организатором
const isParticipantOrganizer = (participantId: string) => {
if (!session) return false;
return (
(session.userId && participantId === session.userId) ||
(session.guestId && participantId === session.guestId)
);
};
// Логируем изменения hasLocalStream для отладки
useEffect(() => {
@@ -180,8 +201,8 @@ function SessionUsersPanel({
name="Вы"
isMuted={isLocalAudioMuted}
isVideoOff={isLocalVideoMuted}
isControlDisabled={false}
isAdmin={true}
hasControl={false}
isAdmin={isLocalUserOrganizer}
isLocal={true}
mediaStream={localStream}
onMute={toggleAudio}
@@ -214,8 +235,8 @@ function SessionUsersPanel({
isMuted={participant.isMuted || false}
isVideoOff={participant.isVideoOff || false}
isSpeaking={participant.isSpeaking}
isControlDisabled={true}
isAdmin={false}
hasControl={false}
isAdmin={isParticipantOrganizer(participant.id) || undefined}
mediaStream={participant.stream}
hasLocalMediaPermission={hasLocalStream}
onMute={() => console.log(`Mute user ${participant.id}`)}
@@ -1,6 +1,5 @@
import PopupWrapper from "../PopupWrapper";
import ActionsPopover from "../ui/ActionsPopover";
import MicrophoneFilledIcon from "../icons/MicrophoneFilledIcon";
import VideoOffFilledIcon from "../icons/VideoOffFilledIcon";
import HandRaisedFilledIcon from "../icons/HandRaisedFilledIcon";
import XMarkFilledIcon from "../icons/XMarkFilledIcon";
@@ -160,19 +159,32 @@ function ParticipantItem({
// Логируем каждый рендер
console.log(
`[ParticipantItem RENDER] ${isLocal ? "Local" : "Remote"} ${
participant.id.slice(0, 8)
} - isMuted=${isMuted}, isVideoOff=${isVideoOff}, hasStream=${!!participant.stream}`
`[ParticipantItem RENDER] ${
isLocal ? "Local" : "Remote"
} ${participant.id.slice(
0,
8
)} - isMuted=${isMuted}, isVideoOff=${isVideoOff}, hasStream=${!!participant.stream}`
);
// Логируем состояние участника для отладки
useEffect(() => {
console.log(
`[ParticipantItem] ${isLocal ? "Local" : "Remote"} ${
participant.id.slice(0, 8)
} - isMuted: ${isMuted}, isVideoOff: ${isVideoOff}, participant.isMuted: ${participant.isMuted}, participant.isVideoOff: ${participant.isVideoOff}`
`[ParticipantItem] ${isLocal ? "Local" : "Remote"} ${participant.id.slice(
0,
8
)} - isMuted: ${isMuted}, isVideoOff: ${isVideoOff}, participant.isMuted: ${
participant.isMuted
}, participant.isVideoOff: ${participant.isVideoOff}`
);
}, [isMuted, isVideoOff, isLocal, participant.id, participant.isMuted, participant.isVideoOff]);
}, [
isMuted,
isVideoOff,
isLocal,
participant.id,
participant.isMuted,
participant.isVideoOff,
]);
// Определяем, является ли этот конкретный участник организатором сессии
const isThisParticipantOrganizer =
@@ -220,7 +232,7 @@ function ParticipantItem({
<ActionsPopover
options={[
{
icon: <MicrophoneFilledIcon />,
icon: <MicrophoneOffFilledIcon />,
label: "Выключить микрофон",
onClick: () => {
console.log("Mute participant:", participant.id);
+46 -100
View File
@@ -1,11 +1,8 @@
import { useEffect, useRef, useState } from "react";
import HandRaisedOffFilledIcon from "../icons/HandRaisedOffFilledIcon";
import HandRaisedFilledIcon from "../icons/HandRaisedFilledIcon";
import MicrophoneFilledIcon from "../icons/MicrophoneFilledIcon";
import VideoOffFilledIcon from "../icons/VideoOffFilledIcon";
import MicrophoneOffIcon from "../icons/MicrophoneOffIcon";
import VideoFilledIcon from "../icons/VideoFilledIcon";
import ControlButton from "./ControlButton";
import Admin from "../indicators/Admin";
import clsx from "clsx";
import VolumeIcon from "../icons/VolumeIcon";
@@ -16,17 +13,13 @@ import MicrophoneOffFilledIcon from "../icons/MicrophoneOffFilledIcon";
interface UserCameraControlsProps {
isMuted: boolean;
isVideoOff: boolean;
isControlDisabled: boolean;
isAdmin: boolean;
onMute: () => void;
onVideoOff: () => void;
onCanControl: () => void;
hasControl: boolean;
}
interface UserCameraProps {
isMuted: boolean;
isVideoOff: boolean;
isControlDisabled: boolean;
hasControl?: boolean;
onMute: () => void;
onVideoOff: () => void;
onCanControl: () => void;
@@ -44,10 +37,10 @@ interface UserCameraProps {
export default function UserCamera({
isMuted,
isVideoOff,
isControlDisabled,
onMute,
onVideoOff,
onCanControl,
hasControl = false,
// onMute,
// onVideoOff,
// onCanControl,
isAdmin = false,
name = "Гость",
mediaStream = null,
@@ -299,11 +292,11 @@ export default function UserCamera({
return (
<div
className={clsx(
"select-none group relative pointer-events-auto",
mode === "mini"
? "aspect-square h-fit group relative flex-shrink-0 pointer-events-auto 2xl:hover:w-[10.833vw] 2xl:w-[6.944vw] sm:w-[15.625vw] w-[27.778vw]"
: "aspect-video group relative pointer-events-auto max-w-full 2xl:h-full object-cover",
? "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",
// isVideoOff ? "bg-green-500" : "bg-yellow-500/10",
className
)}
style={{
@@ -321,39 +314,12 @@ export default function UserCamera({
}}
onClick={handleVideoClick}
>
{isAdmin && mode === "mini" && (
<Admin className="absolute top-0 right-0 z-10" />
)}
{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>
)}
{/* Заглушка когда нет видео */}
{(!mediaStream || isVideoOff) && (
// <div className="flex absolute inset-0 justify-center items-center bg-gradient-to-br from-gray-700 to-gray-900">
// <div className="flex flex-col gap-2 items-center text-white/60">
// <div className="2xl:size-[2.778vw] size-10">
// <VideoOffFilledIcon />
// </div>
// <span className="text-xs">Нет видео</span>
// </div>
// </div>
<div className="2xl:rounded-[1.667vw] rounded-2xl absolute inset-0 bg-white/15 flex items-start justify-center">
<div className="rounded-full 2xl:w-1/3 aspect-square bg-[#7B60F3] mt-12 flex items-center justify-center text-6xl font-medium text-white">
ВД
</div>
</div>
)}
<video
ref={ref}
className={clsx(
"object-cover size-full 2xl:rounded-[1.667vw] rounded-2xl",
isLocal && "scale-x-[-1]",
(!mediaStream || isVideoOff) && "hidden"
isLocal && "scale-x-[-1]"
// (!mediaStream || isVideoOff) && ""
)}
autoPlay
muted={isLocal ? true : isAudioMuted}
@@ -418,6 +384,20 @@ export default function UserCamera({
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 && (
@@ -440,27 +420,18 @@ export default function UserCamera({
{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>
{isMuted && (
<div className="2xl:size-[1.111vw] size-4 text-white/50">
<MicrophoneOffFilledIcon />
</div>
)}
{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}
isControlDisabled={isControlDisabled}
isAdmin={isAdmin || false}
onMute={onMute}
onVideoOff={onVideoOff}
onCanControl={onCanControl}
hasControl={hasControl}
/>
)}
</div>
@@ -470,58 +441,33 @@ export default function UserCamera({
function UserCameraControls({
isMuted,
isVideoOff,
isControlDisabled,
isAdmin,
onMute,
onVideoOff,
onCanControl,
hasControl,
}: UserCameraControlsProps) {
return (
<div className="absolute transition-[bottom] duration-300 2xl:bottom-[0.278vw] 2xl:group-hover:bottom-[0.556vw] group-hover:bottom-2 bottom-1 left-1/2 -translate-x-1/2">
{/* Индикатор muted - показывается всегда */}
<div
className={clsx(
"2xl:size-[1.667vw] size-6 bg-[#14141426] backdrop-blur-[4px] transition-opacity duration-300 rounded-full flex items-center justify-center z-10a absolute left-1/2 -translate-x-1/2 2xl:bottom-0 [0.278vw] group-hover:opacity-0",
isMuted ? "opacity-100" : "opacity-0"
)}
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="size-[0.972vw] text-white">
<MicrophoneOffIcon />
{/* Индикатор микрофона */}
<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>
{/* Кнопки управления - только для администраторов */}
{isAdmin && (
<div
className="flex gap-[0.278vw] mb-[0.278vw] group-hover:opacity-100 opacity-0 transition-opacity duration-300"
onMouseDown={(e) => e.stopPropagation()}
>
<ControlButton
icon={
isMuted ? <MicrophoneOffFilledIcon /> : <MicrophoneFilledIcon />
}
size={"small"}
disabled={false}
onClick={onMute}
/>
<ControlButton
icon={isVideoOff ? <VideoOffFilledIcon /> : <VideoFilledIcon />}
size={"small"}
disabled={false}
onClick={onVideoOff}
/>
<ControlButton
icon={
isControlDisabled ? (
<HandRaisedOffFilledIcon />
) : (
{/* Индикатор видео */}
<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 />
)
}
size={"small"}
disabled={false}
onClick={onCanControl}
/>
</div>
</div>
)}
</div>
+6 -1
View File
@@ -298,7 +298,12 @@ function SessionPage() {
</ActionsSidebarWrapper>
{/* WebRTC видеочат - работает всегда, пока пользователь на странице */}
<SessionUsersPanel roomId={session.id} autoJoin={true} mode={mode} />
<SessionUsersPanel
roomId={session.id}
autoJoin={true}
mode={mode}
session={session}
/>
</div>
);
}