sozsoft-platform/ui/src/views/admin/videoroom/RoomParticipant.tsx
2026-08-11 15:44:56 +03:00

239 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { lazy, Suspense, useMemo, type KeyboardEvent } from 'react'
import { FaMicrophoneSlash, FaUserTimes } from 'react-icons/fa'
import { VideoroomParticipantDto, VideoroomLayoutDto } from '@/proxy/videoroom/models'
import { Button } from '@/components/ui'
const VideoPlayer = lazy(() =>
import('./VideoPlayer').then((module) => ({ default: module.VideoPlayer })),
)
interface RoomParticipantProps {
participants: VideoroomParticipantDto[]
localStream?: MediaStream | null
currentUserId: string
currentUserName: string
isTeacher: boolean
isAudioEnabled: boolean
isVideoEnabled: boolean
onMuteParticipant?: (participantId: string, isMuted: boolean, isTeacher: boolean) => void
layout?: VideoroomLayoutDto
focusedParticipant?: string
onParticipantFocus?: (participantId: string | undefined) => void
onKickParticipant?: (participantId: string) => void
hasSidePanel?: boolean
}
/** Katılımcı sayısına göre kolon sayısı. Satırlar `auto-rows-fr` ile eşit yüksekliğe dağıtılır. */
const getGridColumns = (count: number) => {
if (count <= 1) return 'grid-cols-1'
if (count <= 2) return 'grid-cols-1 sm:grid-cols-2'
if (count <= 4) return 'grid-cols-2 sm:grid-cols-2'
if (count <= 9) return 'grid-cols-2 sm:grid-cols-3'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
}
const VideoTileFallback = () => <div className="h-full w-full animate-pulse bg-gray-900" />
export const RoomParticipant = ({
participants,
localStream,
currentUserId,
currentUserName,
isTeacher,
isAudioEnabled,
isVideoEnabled,
onMuteParticipant,
layout,
focusedParticipant,
onParticipantFocus,
onKickParticipant,
hasSidePanel = false,
}: RoomParticipantProps) => {
const allParticipants = useMemo<VideoroomParticipantDto[]>(() => {
const remote = (participants ?? []).filter((p) => p.id !== currentUserId)
const self: VideoroomParticipantDto = {
id: currentUserId,
name: currentUserName,
sessionId: remote[0]?.sessionId ?? '',
isTeacher,
isAudioMuted: !isAudioEnabled,
isVideoMuted: !isVideoEnabled,
isActive: true,
stream: localStream ?? undefined,
}
return [self, ...remote]
}, [
participants,
currentUserId,
currentUserName,
isTeacher,
isAudioEnabled,
isVideoEnabled,
localStream,
])
const renderParticipant = (
participant: VideoroomParticipantDto,
{ isMain = false }: { isMain?: boolean } = {},
) => {
const isSelf = participant.id === currentUserId
const isFocusable = !isMain && !!onParticipantFocus
const canModerate = isTeacher && !isSelf
return (
<div
className={`relative h-full w-full min-h-0 min-w-0 ${isFocusable ? 'cursor-pointer' : ''}`}
{...(isFocusable
? {
role: 'button' as const,
tabIndex: 0,
title: `${participant.name} görüntüsünü büyüt`,
onClick: () => onParticipantFocus?.(participant.id),
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onParticipantFocus?.(participant.id)
}
},
}
: {})}
>
<Suspense fallback={<VideoTileFallback />}>
<VideoPlayer
stream={participant.stream}
isLocal={isSelf}
userName={participant.name}
isAudioEnabled={isSelf ? isAudioEnabled : !participant.isAudioMuted}
isVideoEnabled={isSelf ? isVideoEnabled : !participant.isVideoMuted}
/>
</Suspense>
{participant.isHandRaised && !isSelf && (
<div className="absolute right-1 top-8 rounded-full bg-yellow-500 px-2 py-0.5 text-xs text-white sm:right-2 sm:top-10">
</div>
)}
{canModerate && (
<div className="absolute left-2 top-2 z-10 flex space-x-1">
<Button
type="button"
variant="plain"
shape="circle"
onClick={(e) => {
e.stopPropagation()
onMuteParticipant?.(participant.id, !participant.isAudioMuted, isTeacher)
}}
className={`!inline-flex !h-auto items-center justify-center rounded-full !p-1 text-xs text-white ${
participant.isAudioMuted ? '!bg-red-600' : '!bg-gray-600 hover:!bg-gray-700'
} transition-colors`}
title={participant.isAudioMuted ? 'Sesi Aç' : 'Sesi Kapat'}
>
<FaMicrophoneSlash size={12} />
</Button>
{onKickParticipant && (
<Button
type="button"
variant="plain"
shape="circle"
onClick={(e) => {
e.stopPropagation()
onKickParticipant(participant.id)
}}
className="!inline-flex !h-auto items-center justify-center rounded-full !bg-red-600 !p-1 text-xs text-white transition-colors hover:!bg-red-700"
title="Sınıftan Çıkar"
>
<FaUserTimes size={12} />
</Button>
)}
</div>
)}
</div>
)
}
const renderGridLayout = () => {
const count = allParticipants.length
const gap = count <= 4 ? 'gap-2 sm:gap-3' : 'gap-1 sm:gap-2'
const padding = count === 1 ? 'p-0' : count <= 4 ? 'p-2 sm:p-4' : 'p-1 sm:p-2'
return (
<div className={`h-full min-h-0 overflow-y-auto ${padding}`}>
<div
className={`grid h-full min-h-0 auto-rows-fr ${getGridColumns(count)} ${gap} place-items-stretch`}
>
{allParticipants.map((participant, index) => (
<div
key={participant.id}
className={`min-h-0 min-w-0 overflow-hidden rounded-lg sm:rounded-xl ${
// Mobilde ilk katılımcı tam genişlik kaplar
index === 0 && count > 2 ? 'col-span-2 sm:col-span-1' : ''
}`}
>
{renderParticipant(participant)}
</div>
))}
</div>
</div>
)
}
const renderSidebarLayout = () => {
const mainParticipant =
allParticipants.find((p) => p.id === focusedParticipant) ?? allParticipants[0]
const otherParticipants = allParticipants.filter((p) => p.id !== mainParticipant.id)
const sidebarWidth = hasSidePanel
? 'w-20 sm:w-24 md:w-32 lg:w-40'
: 'w-24 sm:w-32 md:w-40 lg:w-48'
return (
<div className="flex h-full min-h-0">
<div className="flex min-w-0 flex-1 items-center justify-center p-1 sm:p-2">
<div className="h-full w-full overflow-hidden rounded-xl">
{renderParticipant(mainParticipant, { isMain: true })}
</div>
</div>
{otherParticipants.length > 0 && (
<div className={`${sidebarWidth} h-full min-h-0 overflow-y-auto p-2`}>
<div className="flex min-w-0 flex-col gap-2">
{otherParticipants.map((participant) => (
<div
key={participant.id}
className="aspect-video shrink-0 overflow-hidden rounded-lg border border-blue-300/40 shadow shadow-blue-200/20 transition-all duration-200"
>
{renderParticipant(participant)}
</div>
))}
</div>
</div>
)}
</div>
)
}
const renderTeacherFocusLayout = () => {
const teacher = allParticipants.find((p) => p.isTeacher) ?? allParticipants[0]
return (
<div className="h-full min-h-0 overflow-hidden">
<div className="h-full w-full overflow-hidden rounded-lg sm:rounded-xl">
{renderParticipant(teacher, { isMain: true })}
</div>
</div>
)
}
// Ne yerel yayın ne de katılımcı varsa gösterilecek bir şey yok
if (!localStream && allParticipants.length <= 1) {
return null
}
switch (layout?.type) {
case 'sidebar':
return <div className="h-full min-h-0">{renderSidebarLayout()}</div>
case 'teacher-focus':
return <div className="h-full min-h-0">{renderTeacherFocusLayout()}</div>
default:
return <div className="h-full min-h-0">{renderGridLayout()}</div>
}
}