2026-08-11 12:44:56 +00:00
|
|
|
|
import { lazy, Suspense, useMemo, type KeyboardEvent } from 'react'
|
2026-07-06 09:44:26 +00:00
|
|
|
|
import { FaMicrophoneSlash, FaUserTimes } from 'react-icons/fa'
|
2026-05-08 05:34:29 +00:00
|
|
|
|
import { VideoroomParticipantDto, VideoroomLayoutDto } from '@/proxy/videoroom/models'
|
2026-06-25 22:55:44 +00:00
|
|
|
|
import { Button } from '@/components/ui'
|
2026-05-08 05:34:29 +00:00
|
|
|
|
|
2026-07-11 20:35:32 +00:00
|
|
|
|
const VideoPlayer = lazy(() =>
|
|
|
|
|
|
import('./VideoPlayer').then((module) => ({ default: module.VideoPlayer })),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-08 05:34:29 +00:00
|
|
|
|
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
|
2026-08-11 12:44:56 +00:00
|
|
|
|
layout?: VideoroomLayoutDto
|
2026-05-08 05:34:29 +00:00
|
|
|
|
focusedParticipant?: string
|
|
|
|
|
|
onParticipantFocus?: (participantId: string | undefined) => void
|
|
|
|
|
|
onKickParticipant?: (participantId: string) => void
|
|
|
|
|
|
hasSidePanel?: boolean
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 12:44:56 +00:00
|
|
|
|
/** 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" />
|
|
|
|
|
|
|
2026-07-06 09:44:26 +00:00
|
|
|
|
export const RoomParticipant = ({
|
2026-05-08 05:34:29 +00:00
|
|
|
|
participants,
|
|
|
|
|
|
localStream,
|
|
|
|
|
|
currentUserId,
|
|
|
|
|
|
currentUserName,
|
|
|
|
|
|
isTeacher,
|
|
|
|
|
|
isAudioEnabled,
|
|
|
|
|
|
isVideoEnabled,
|
|
|
|
|
|
onMuteParticipant,
|
|
|
|
|
|
layout,
|
|
|
|
|
|
focusedParticipant,
|
|
|
|
|
|
onParticipantFocus,
|
|
|
|
|
|
onKickParticipant,
|
|
|
|
|
|
hasSidePanel = false,
|
2026-07-06 09:44:26 +00:00
|
|
|
|
}: RoomParticipantProps) => {
|
2026-08-11 12:44:56 +00:00
|
|
|
|
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,
|
2026-05-08 05:34:29 +00:00
|
|
|
|
isTeacher,
|
2026-08-11 12:44:56 +00:00
|
|
|
|
isAudioEnabled,
|
|
|
|
|
|
isVideoEnabled,
|
|
|
|
|
|
localStream,
|
|
|
|
|
|
])
|
2026-05-08 05:34:29 +00:00
|
|
|
|
|
2026-08-11 12:44:56 +00:00
|
|
|
|
const renderParticipant = (
|
|
|
|
|
|
participant: VideoroomParticipantDto,
|
|
|
|
|
|
{ isMain = false }: { isMain?: boolean } = {},
|
|
|
|
|
|
) => {
|
|
|
|
|
|
const isSelf = participant.id === currentUserId
|
|
|
|
|
|
const isFocusable = !isMain && !!onParticipantFocus
|
|
|
|
|
|
const canModerate = isTeacher && !isSelf
|
2026-05-08 05:34:29 +00:00
|
|
|
|
|
2026-08-11 12:44:56 +00:00
|
|
|
|
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>
|
2026-05-08 05:34:29 +00:00
|
|
|
|
|
2026-08-11 12:44:56 +00:00
|
|
|
|
{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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-05-08 05:34:29 +00:00
|
|
|
|
|
|
|
|
|
|
const renderGridLayout = () => {
|
2026-08-11 12:44:56 +00:00
|
|
|
|
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'
|
2026-05-08 05:34:29 +00:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-08-11 12:44:56 +00:00
|
|
|
|
<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) => (
|
2026-05-08 05:34:29 +00:00
|
|
|
|
<div
|
2026-08-11 12:44:56 +00:00
|
|
|
|
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' : ''
|
|
|
|
|
|
}`}
|
2026-05-08 05:34:29 +00:00
|
|
|
|
>
|
2026-08-11 12:44:56 +00:00
|
|
|
|
{renderParticipant(participant)}
|
2026-05-08 05:34:29 +00:00
|
|
|
|
</div>
|
2026-08-11 12:44:56 +00:00
|
|
|
|
))}
|
2026-05-08 05:34:29 +00:00
|
|
|
|
</div>
|
2026-08-11 12:44:56 +00:00
|
|
|
|
</div>
|
2026-05-08 05:34:29 +00:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const renderSidebarLayout = () => {
|
2026-08-11 12:44:56 +00:00
|
|
|
|
const mainParticipant =
|
|
|
|
|
|
allParticipants.find((p) => p.id === focusedParticipant) ?? allParticipants[0]
|
2026-05-08 05:34:29 +00:00
|
|
|
|
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 (
|
2026-08-11 12:44:56 +00:00
|
|
|
|
<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)}
|
2026-05-08 05:34:29 +00:00
|
|
|
|
</div>
|
2026-08-11 12:44:56 +00:00
|
|
|
|
))}
|
2026-05-08 05:34:29 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-08-11 12:44:56 +00:00
|
|
|
|
)}
|
2026-05-08 05:34:29 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const renderTeacherFocusLayout = () => {
|
2026-08-11 12:44:56 +00:00
|
|
|
|
const teacher = allParticipants.find((p) => p.isTeacher) ?? allParticipants[0]
|
2026-05-08 05:34:29 +00:00
|
|
|
|
return (
|
2026-08-11 12:44:56 +00:00
|
|
|
|
<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 })}
|
2026-05-08 05:34:29 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 12:44:56 +00:00
|
|
|
|
// Ne yerel yayın ne de katılımcı varsa gösterilecek bir şey yok
|
|
|
|
|
|
if (!localStream && allParticipants.length <= 1) {
|
|
|
|
|
|
return null
|
2026-05-08 05:34:29 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 12:44:56 +00:00
|
|
|
|
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>
|
|
|
|
|
|
}
|
2026-05-08 05:34:29 +00:00
|
|
|
|
}
|