283 lines
11 KiB
TypeScript
283 lines
11 KiB
TypeScript
|
|
import React from 'react'
|
|||
|
|
import { FaMicrophoneSlash, FaExpand, FaUserTimes } from 'react-icons/fa'
|
|||
|
|
import { VideoPlayer } from './VideoPlayer'
|
|||
|
|
import { VideoroomParticipantDto, VideoroomLayoutDto } from '@/proxy/videoroom/models'
|
|||
|
|
|
|||
|
|
interface RoomParticipantProps {
|
|||
|
|
participants: VideoroomParticipantDto[]
|
|||
|
|
localStream?: MediaStream | null
|
|||
|
|
currentUserId: string
|
|||
|
|
currentUserName: string
|
|||
|
|
isTeacher: boolean
|
|||
|
|
isAudioEnabled: boolean
|
|||
|
|
isVideoEnabled: boolean
|
|||
|
|
onToggleAudio: () => void
|
|||
|
|
onToggleVideo: () => void
|
|||
|
|
onLeaveCall: () => void
|
|||
|
|
onMuteParticipant?: (participantId: string, isMuted: boolean, isTeacher: boolean) => void
|
|||
|
|
layout: VideoroomLayoutDto
|
|||
|
|
focusedParticipant?: string
|
|||
|
|
onParticipantFocus?: (participantId: string | undefined) => void
|
|||
|
|
onKickParticipant?: (participantId: string) => void
|
|||
|
|
hasSidePanel?: boolean
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export const RoomParticipant: React.FC<RoomParticipantProps> = ({
|
|||
|
|
participants,
|
|||
|
|
localStream,
|
|||
|
|
currentUserId,
|
|||
|
|
currentUserName,
|
|||
|
|
isTeacher,
|
|||
|
|
isAudioEnabled,
|
|||
|
|
isVideoEnabled,
|
|||
|
|
onToggleAudio,
|
|||
|
|
onToggleVideo,
|
|||
|
|
onLeaveCall,
|
|||
|
|
onMuteParticipant,
|
|||
|
|
layout,
|
|||
|
|
focusedParticipant,
|
|||
|
|
onParticipantFocus,
|
|||
|
|
onKickParticipant,
|
|||
|
|
hasSidePanel = false,
|
|||
|
|
}) => {
|
|||
|
|
// Only show current user's video once
|
|||
|
|
const currentUserParticipant = {
|
|||
|
|
id: currentUserId,
|
|||
|
|
name: currentUserName,
|
|||
|
|
isTeacher,
|
|||
|
|
stream: localStream ?? undefined, // null yerine undefined
|
|||
|
|
} as unknown as VideoroomParticipantDto
|
|||
|
|
|
|||
|
|
// Eğer hiç katılımcı yoksa ve localStream de yoksa hiçbir şey render etme
|
|||
|
|
if (!localStream && (!participants || participants.length === 0)) {
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const allParticipants = [currentUserParticipant, ...participants]
|
|||
|
|
|
|||
|
|
// Ortak ana video kutusu container class'ı
|
|||
|
|
const mainVideoContainerClass = 'w-full h-full flex flex-col justify-center'
|
|||
|
|
|
|||
|
|
const renderGridLayout = () => {
|
|||
|
|
const getGridClass = (participantCount: number) => {
|
|||
|
|
if (participantCount === 1) return 'grid-cols-1'
|
|||
|
|
if (participantCount <= 2) return 'grid-cols-1 sm:grid-cols-2'
|
|||
|
|
if (participantCount <= 4) return 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-2'
|
|||
|
|
if (participantCount <= 6) return 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3'
|
|||
|
|
if (participantCount <= 9) return 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3'
|
|||
|
|
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const getGridRows = (participantCount: number) => {
|
|||
|
|
if (participantCount === 1) return 'grid-rows-1'
|
|||
|
|
if (participantCount <= 2) return 'grid-rows-1 sm:grid-rows-1'
|
|||
|
|
if (participantCount <= 4) return 'grid-rows-2 lg:grid-rows-2'
|
|||
|
|
if (participantCount <= 6) return 'grid-rows-3 sm:grid-rows-2'
|
|||
|
|
if (participantCount <= 9) return 'grid-rows-3'
|
|||
|
|
return 'grid-rows-4 sm:grid-rows-3'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const getPadding = (participantCount: number) => {
|
|||
|
|
if (participantCount === 1) return ''
|
|||
|
|
if (participantCount <= 4) return 'p-2 sm:p-4'
|
|||
|
|
return 'p-1 sm:p-2'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const getGap = (participantCount: number) => {
|
|||
|
|
if (participantCount === 1) return 'gap-0'
|
|||
|
|
if (participantCount <= 4) return 'gap-2 sm:gap-3'
|
|||
|
|
return 'gap-1 sm:gap-2'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Mobilde: En üstte öğretmen, altında katılımcılar 2'li grid ve dikey scroll
|
|||
|
|
const mainParticipant = allParticipants[0]
|
|||
|
|
const otherParticipants = allParticipants.slice(1)
|
|||
|
|
return (
|
|||
|
|
<>
|
|||
|
|
{/* Mobil özel layout */}
|
|||
|
|
<div className="sm:hidden w-full h-full flex flex-col items-center overflow-hidden p-2">
|
|||
|
|
{/* Ana katılımcı */}
|
|||
|
|
<div className="w-full max-w-md mx-auto flex-none flex items-center justify-center mb-2">
|
|||
|
|
<div className="w-full aspect-video max-h-[40vh] rounded-xl overflow-hidden flex bg-white/10 shadow-md border border-white/10">
|
|||
|
|
{renderParticipant(mainParticipant, true)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
{/* Diğer katılımcılar 2'li grid ve dikey scroll */}
|
|||
|
|
{otherParticipants.length > 0 && (
|
|||
|
|
<div
|
|||
|
|
className="w-full max-w-md mx-auto flex-1 overflow-y-auto grid grid-cols-1 gap-2 pb-2 min-h-0"
|
|||
|
|
style={{ maxHeight: '55vh' }}
|
|||
|
|
>
|
|||
|
|
{Array.from({ length: Math.ceil(otherParticipants.length / 2) }).map((_, rowIdx) => (
|
|||
|
|
<div key={rowIdx} className="flex gap-2">
|
|||
|
|
{otherParticipants.slice(rowIdx * 2, rowIdx * 2 + 2).map((participant) => (
|
|||
|
|
<div
|
|||
|
|
key={participant.id}
|
|||
|
|
className="flex-1 aspect-video rounded-lg overflow-hidden flex bg-white/10 shadow border border-white/10"
|
|||
|
|
>
|
|||
|
|
{renderParticipant(participant, false, true)}
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
{otherParticipants.length % 2 === 1 &&
|
|||
|
|
rowIdx === Math.floor(otherParticipants.length / 2) ? (
|
|||
|
|
<div className="flex-1" />
|
|||
|
|
) : null}
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
{/* Masaüstü ve tablet için eski grid layout */}
|
|||
|
|
<div className="hidden sm:flex h-full items-center justify-center overflow-hidden">
|
|||
|
|
<div
|
|||
|
|
className={`w-full h-full flex flex-col justify-center ${getPadding(allParticipants.length)}`}
|
|||
|
|
>
|
|||
|
|
<div
|
|||
|
|
className={`h-full grid ${getGridClass(allParticipants.length)} ${getGridRows(allParticipants.length)} ${getGap(allParticipants.length)} place-items-stretch`}
|
|||
|
|
>
|
|||
|
|
{allParticipants.map((participant) => (
|
|||
|
|
<div
|
|||
|
|
key={participant.id}
|
|||
|
|
className="w-full h-full max-h-full flex items-stretch justify-stretch min-h-0"
|
|||
|
|
>
|
|||
|
|
<div className="w-full h-full rounded-lg sm:rounded-xl overflow-hidden flex">
|
|||
|
|
{renderParticipant(participant, false)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const renderSidebarLayout = () => {
|
|||
|
|
const mainParticipant = focusedParticipant
|
|||
|
|
? allParticipants.find((p) => p.id === focusedParticipant) || allParticipants[0]
|
|||
|
|
: 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'
|
|||
|
|
|
|||
|
|
// Eğer hiç katılımcı yoksa, video player öğretmen odaklı gibi ortalanır ve geniş olur
|
|||
|
|
return (
|
|||
|
|
<div className="h-screen flex items-center justify-center p-0">
|
|||
|
|
<div className={mainVideoContainerClass + ' h-full'}>
|
|||
|
|
<div className="flex h-full">
|
|||
|
|
<div className={`flex-1 min-w-0 flex items-center justify-center`}>
|
|||
|
|
<div className="w-full h-full flex items-center justify-center">
|
|||
|
|
<div className="w-full h-full rounded-xl overflow-hidden transition-all duration-200">
|
|||
|
|
{renderParticipant(mainParticipant, true)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
{otherParticipants.length > 0 && (
|
|||
|
|
<div className={`${sidebarWidth} p-2 overflow-y-auto rounded-l-lg h-full`}>
|
|||
|
|
<div className="flex flex-col gap-2 h-full min-w-0">
|
|||
|
|
{otherParticipants.map((participant) => (
|
|||
|
|
<div
|
|||
|
|
key={participant.id}
|
|||
|
|
className="rounded-lg border border-blue-300/40 shadow shadow-blue-200/20 backdrop-blur-sm transition-all duration-200"
|
|||
|
|
>
|
|||
|
|
{renderParticipant(participant, false, true)}
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const renderTeacherFocusLayout = () => {
|
|||
|
|
// Sadece öğretmen gösterilecek, katılımcılar asla gösterilmeyecek
|
|||
|
|
const teacher = allParticipants.find((p) => p.isTeacher) || allParticipants[0]
|
|||
|
|
return (
|
|||
|
|
<div className="h-full flex items-center justify-center overflow-hidden">
|
|||
|
|
<div className="w-full h-full flex flex-col justify-center ">
|
|||
|
|
<div className="h-full w-full max-h-full flex items-center justify-center">
|
|||
|
|
<div className="w-full h-full rounded-lg sm:rounded-xl overflow-hidden">
|
|||
|
|
{renderParticipant(teacher, true)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const renderParticipant = (
|
|||
|
|
participant: VideoroomParticipantDto,
|
|||
|
|
isMain: boolean = false,
|
|||
|
|
isSmall: boolean = false,
|
|||
|
|
) => (
|
|||
|
|
<div
|
|||
|
|
key={participant.id}
|
|||
|
|
className={`relative w-full h-full ${isMain ? '' : isSmall ? 'aspect-video' : ''} ${!isMain && onParticipantFocus ? 'cursor-pointer' : ''}`}
|
|||
|
|
onClick={() => !isMain && onParticipantFocus?.(participant.id)}
|
|||
|
|
style={{ minHeight: 0, minWidth: 0 }}
|
|||
|
|
>
|
|||
|
|
<div className="absolute inset-0 w-full h-full">
|
|||
|
|
<VideoPlayer
|
|||
|
|
stream={participant.stream}
|
|||
|
|
isLocal={participant.id === currentUserId}
|
|||
|
|
userName={participant.name}
|
|||
|
|
isAudioEnabled={
|
|||
|
|
participant.id === currentUserId ? isAudioEnabled : !participant.isAudioMuted
|
|||
|
|
}
|
|||
|
|
isVideoEnabled={
|
|||
|
|
participant.id === currentUserId ? isVideoEnabled : !participant.isVideoMuted
|
|||
|
|
}
|
|||
|
|
onToggleAudio={participant.id === currentUserId ? onToggleAudio : undefined}
|
|||
|
|
onToggleVideo={participant.id === currentUserId ? onToggleVideo : undefined}
|
|||
|
|
onLeaveCall={participant.id === currentUserId ? onLeaveCall : undefined}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
{/* Teacher controls for students */}
|
|||
|
|
{isTeacher && participant.id !== currentUserId && (
|
|||
|
|
<div className="absolute top-2 left-2 flex space-x-1 z-10">
|
|||
|
|
<button
|
|||
|
|
onClick={(e) => {
|
|||
|
|
e.stopPropagation()
|
|||
|
|
onMuteParticipant?.(participant.id, !participant.isAudioMuted, isTeacher)
|
|||
|
|
}}
|
|||
|
|
className={`p-1 rounded-full text-white text-xs ${
|
|||
|
|
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>
|
|||
|
|
<button
|
|||
|
|
onClick={(e) => {
|
|||
|
|
e.stopPropagation()
|
|||
|
|
onKickParticipant?.(participant.id)
|
|||
|
|
}}
|
|||
|
|
className="p-1 rounded-full bg-red-600 hover:bg-red-700 text-white text-xs transition-colors"
|
|||
|
|
title="Sınıftan Çıkar"
|
|||
|
|
>
|
|||
|
|
<FaUserTimes size={12} />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const renderLayout = () => {
|
|||
|
|
switch (layout.type) {
|
|||
|
|
case 'sidebar':
|
|||
|
|
return renderSidebarLayout()
|
|||
|
|
case 'teacher-focus':
|
|||
|
|
return renderTeacherFocusLayout()
|
|||
|
|
default:
|
|||
|
|
return renderGridLayout()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return <div className="h-full min-h-0 flex flex-col">{renderLayout()}</div>
|
|||
|
|
}
|