import { useState, useEffect, useRef, type FormEvent } from 'react' import { motion } from 'framer-motion' import { FaComments, FaExpand, FaHandPaper, FaVolumeMute, FaVolumeUp, FaFile, FaDesktop, FaMicrophone, FaMicrophoneSlash, FaVideo, FaVideoSlash, FaPhone, FaTimes, FaCompress, FaUserFriends, FaLayerGroup, FaFilePdf, FaFileWord, FaFileImage, FaFileAlt, FaBars, } from 'react-icons/fa' import { SignalRService } from '@/services/videoroom/signalr' import { WebRTCService } from '@/services/videoroom/webrtc' import { useStoreState } from '@/store/store' import { KickParticipantModal } from '@/views/admin/videoroom/KickParticipantModal' import { getVideoroomAttandances, getVideoroomById, getVideoroomChats, endVideoroom } from '@/services/videoroom.service' import { showDbDateAsIs } from '@/utils/dateUtils' import { useNavigate, useParams } from 'react-router-dom' import { ROUTES_ENUM } from '@/routes/route.constant' import { Helmet } from 'react-helmet' import { useLocalization } from '@/utils/hooks/useLocalization' import ChatPanel from '@/views/admin/videoroom/ChatPanel' import ParticipantsPanel from '@/views/admin/videoroom/ParticipantsPanel' import DocumentsPanel from '@/views/admin/videoroom/DocumentsPanel' import LayoutPanel from '@/views/admin/videoroom/LayoutPanel' import { ScreenSharePanel } from '@/views/admin/videoroom/ScreenSharePanel' import { RoomParticipant } from '@/views/admin/videoroom/RoomParticipant' import toast from '@/components/ui/toast/toast' import Notification from '@/components/ui/Notification' import { Button } from '@/components/ui' import { VideoroomDocumentDto, VideoroomAttendanceDto, VideoroomChatDto, VideoroomDto, VideoroomParticipantDto, VideoroomSettingsDto, MessageType, VideoroomLayoutDto, } from '@/proxy/videoroom/models' type SidePanelType = | 'chat' | 'participants' | 'documents' | 'handraises' | 'layout' | 'settings' | null const newClassSession: VideoroomDto = { id: '', name: '', teacherId: '', teacherName: '', scheduledStartTime: '', scheduledEndTime: '', actualStartTime: '', actualEndTime: '', participantCount: 0, settingsDto: undefined, } const RoomDetail = () => { const handleDismissHandRaise = async (participantId: string) => { if (signalRServiceRef.current && user.role === 'teacher') { await signalRServiceRef.current.dismissHandRaise(classSession.id, participantId) } } const params = useParams() const navigate = useNavigate() const { user } = useStoreState((state) => state.auth) const { translate } = useLocalization() const [classSession, setClassSession] = useState(newClassSession) const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [participants, setParticipants] = useState([]) const [localStream, setLocalStream] = useState(null) const [isAudioEnabled, setIsAudioEnabled] = useState(true) const [isVideoEnabled, setIsVideoEnabled] = useState(true) const [attendanceRecords, setAttendanceRecords] = useState([]) const [chatMessages, setChatMessages] = useState([]) const [currentLayout, setCurrentLayout] = useState(null) const [focusedParticipant, setFocusedParticipant] = useState() const [hasRaisedHand, setHasRaisedHand] = useState(false) const [isAllMuted, setIsAllMuted] = useState(false) const [kickingParticipant, setKickingParticipant] = useState<{ id: string; name: string } | null>( null, ) const [documents, setDocuments] = useState([]) const [isScreenSharing, setIsScreenSharing] = useState(false) const [screenStream, setScreenStream] = useState() const [screenSharer, setScreenSharer] = useState() const [isFullscreen, setIsFullscreen] = useState(false) const [activeSidePanel, setActiveSidePanel] = useState(null) const [newMessage, setNewMessage] = useState('') const [messageMode, setMessageMode] = useState('public') const [selectedRecipient, setSelectedRecipient] = useState<{ id: string; name: string } | null>( null, ) const raisedHandsCount = participants.filter((p) => p.isHandRaised).length const messagesEndRef = useRef(null) const [classSettings, setClassSettings] = useState({ allowHandRaise: true, defaultMicrophoneState: 'muted', defaultCameraState: 'on', defaultLayout: 'grid', allowStudentScreenShare: false, allowStudentChat: true, allowPrivateMessages: true, autoMuteNewParticipants: true, }) const signalRServiceRef = useRef() const webRTCServiceRef = useRef() const [teacherDisconnected, setTeacherDisconnected] = useState(false) const layouts: VideoroomLayoutDto[] = [ { id: 'grid', name: 'Izgara Görünümü', type: 'grid', description: 'Tüm katılımcılar eşit boyutta görünür', }, { id: 'sidebar', name: 'Sunum Modu', type: 'sidebar', description: 'Ana konuşmacı büyük, diğerleri yan panelde', }, { id: 'teacher-focus', name: 'Öğretmen Odaklı', type: 'teacher-focus', description: 'Öğretmen tam ekranda görünür, öğrenciler küçük panelde', }, ] const fetchClassAttendances = async () => { if (!params?.id) return const attResult = await getVideoroomAttandances(params.id) if (attResult && attResult.data) { setAttendanceRecords(attResult.data) } } const fetchClassChats = async () => { if (!params?.id) return const chatResult = await getVideoroomChats(params.id) if (chatResult && chatResult.data) { setChatMessages(chatResult.data || []) } } const fetchClassDetails = async () => { const classEntity = await getVideoroomById(params?.id ?? '') if (classEntity) { classEntity.data.scheduledStartTime = showDbDateAsIs(classEntity.data.scheduledStartTime) setClassSession(classEntity.data) } } useEffect(() => { fetchClassDetails() fetchClassChats() fetchClassAttendances() }, []) useEffect(() => { if (classSession.id) { initializeServices() return () => { cleanup() } } }, [classSession.id]) // Apply class settings useEffect(() => { if (classSession?.settingsDto) { setClassSettings(classSession.settingsDto) const selectedLayout = layouts.find((l) => l.id === classSession.settingsDto!.defaultLayout) || layouts[0] setCurrentLayout(selectedLayout) // Apply default audio/video states for new participants if (user.role === 'student') { setIsAudioEnabled(classSession.settingsDto.defaultMicrophoneState === 'unmuted') setIsVideoEnabled(classSession.settingsDto.defaultCameraState === 'on') } } }, [classSession?.settingsDto, user.role]) useEffect(() => { scrollToBottom() }, [chatMessages]) const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) } const initializeServices = async () => { try { // Initialize SignalR signalRServiceRef.current = new SignalRService() await signalRServiceRef.current.start() const micEnabled = classSession.settingsDto?.defaultMicrophoneState === 'unmuted' const camEnabled = classSession.settingsDto?.defaultCameraState === 'on' // WebRTC başlat webRTCServiceRef.current = new WebRTCService() webRTCServiceRef.current.setSignalRService(signalRServiceRef.current, classSession.id) const stream = await webRTCServiceRef.current.initializeLocalStream(micEnabled, camEnabled) if (stream) { setLocalStream(stream) } setIsAudioEnabled(micEnabled) setIsVideoEnabled(camEnabled) // Setup WebRTC remote stream handler webRTCServiceRef.current.onRemoteStreamReceived((userId, stream) => { setParticipants((prev) => prev.map((p) => (p.id === userId ? { ...p, stream } : p))) }) webRTCServiceRef.current.setIceCandidateHandler(async (toUserId, candidate) => { if (signalRServiceRef.current) { await signalRServiceRef.current.sendIceCandidate(classSession.id, toUserId, candidate) } }) signalRServiceRef.current.setOfferReceivedHandler(async (fromUserId, offer) => { if (!webRTCServiceRef.current?.getPeerConnection(fromUserId)) { await webRTCServiceRef.current?.createPeerConnection(fromUserId) } const answer = await webRTCServiceRef.current?.createAnswer(fromUserId, offer) if (answer) { await signalRServiceRef.current?.sendAnswer(classSession.id, fromUserId, answer) } }) signalRServiceRef.current.setAnswerReceivedHandler(async (fromUserId, answer) => { await webRTCServiceRef.current?.handleAnswer(fromUserId, answer) }) signalRServiceRef.current.setIceCandidateReceivedHandler(async (fromUserId, candidate) => { await webRTCServiceRef.current?.addIceCandidate(fromUserId, candidate) }) // 🔑 Yeni katılan birini gördüğünde signalRServiceRef.current.setParticipantJoinHandler( async (remoteUserId: string, name: string, isTeacher: boolean, isActive: boolean) => { if (remoteUserId === user.id) return if (!isActive) return toast.push(, { placement: 'bottom-end', }) // State’e ekle setParticipants((prev) => { if (prev.find((p) => p.id === remoteUserId)) return prev return [ ...prev, { id: remoteUserId, name, sessionId: classSession.id, isTeacher, isAudioMuted: classSettings.defaultMicrophoneState === 'muted', isVideoMuted: classSettings.defaultCameraState === 'off', isActive: true, }, ] }) // PeerConnection hazırla if (!webRTCServiceRef.current?.getPeerConnection(remoteUserId)) { await webRTCServiceRef.current?.createPeerConnection(remoteUserId) } // 🔑 Çakışmayı önle: sadece id’si küçük olan offer başlatır if (user.id < remoteUserId) { const offer = await webRTCServiceRef.current!.createOffer(remoteUserId) await signalRServiceRef.current?.sendOffer(classSession.id, remoteUserId, offer) } }, ) // 🔑 Odaya girdiğinde var olan katılımcılar signalRServiceRef.current.setExistingParticipantsHandler( async ( existing: { userId: string; userName: string; isTeacher: boolean; isActive: boolean }[], ) => { for (const participant of existing) { if (!participant.isActive) continue if (participant.userId === user.id) continue // State’e ekle setParticipants((prev) => { if (prev.find((p) => p.id === participant.userId)) return prev return [ ...prev, { id: participant.userId, name: participant.userName, sessionId: classSession.id, isTeacher: participant.isTeacher, isAudioMuted: classSettings.defaultMicrophoneState === 'muted', isVideoMuted: classSettings.defaultCameraState === 'off', isActive: true, }, ] }) // PeerConnection hazırla if (!webRTCServiceRef.current?.getPeerConnection(participant.userId)) { await webRTCServiceRef.current?.createPeerConnection(participant.userId) } // 🔑 Çakışmayı önle if (user.id < participant.userId) { const offer = await webRTCServiceRef.current!.createOffer(participant.userId) await signalRServiceRef.current?.sendOffer(classSession.id, participant.userId, offer) } } }, ) signalRServiceRef.current.setForceCleanupHandler(() => { webRTCServiceRef.current?.closeAllConnections() localStream?.getTracks().forEach((t) => t.stop()) }) signalRServiceRef.current.setParticipantLeaveHandler(({ userId, sessionId, userName }) => { if (userId !== user.id) { toast.push( , { placement: 'bottom-end' }, ) } // peer connection’ı kapat webRTCServiceRef.current?.closePeerConnection(userId) // katılımcıyı state’den tamamen sil setParticipants((prev) => prev.filter((p) => !(p.id === userId && p.sessionId === sessionId)), ) if (participants.find((p) => p.id === userId)?.isTeacher) { setTeacherDisconnected(true) } }) signalRServiceRef.current.setAttendanceUpdatedHandler((record) => { setAttendanceRecords((prev) => { const existing = prev.find((r) => r.id === record.id) if (existing) { return prev.map((r) => (r.id === record.id ? record : r)) } return [...prev, record] }) }) signalRServiceRef.current.setChatMessageReceivedHandler((message) => { setChatMessages((prev) => [...prev, message]) }) signalRServiceRef.current.setParticipantMutedHandler(async (userId, isMuted) => { setParticipants((prev) => prev.map((p) => (p.id === userId ? { ...p, isAudioMuted: isMuted } : p)), ) // Eğer mute edilen kişi currentUser ise → kendi mikrofonunu kapat if (userId === user.id) { await webRTCServiceRef.current?.toggleAudio(!isMuted) setIsAudioEnabled(!isMuted) } }) // Hand raise events signalRServiceRef.current.setHandRaiseReceivedHandler((studentId) => { setParticipants((prev) => prev.map((p) => (p.id === studentId ? { ...p, isHandRaised: true } : p)), ) }) signalRServiceRef.current.setHandRaiseDismissedHandler((studentId) => { setParticipants((prev) => prev.map((p) => (p.id === studentId ? { ...p, isHandRaised: false } : p)), ) // 👇 kendi state’ini de sıfırla if (studentId === user.id) { setHasRaisedHand(false) } }) // Join the class await signalRServiceRef.current.joinClass( classSession.id, user.id, user.name, user.role === 'teacher', true, ) } catch (error) { console.error('Sınıf servisleri başlatılamadı:', error) toast.push( , { placement: 'bottom-end' }, ) } } const cleanup = async () => { if (signalRServiceRef.current) { // ✅ önce LeaveClass await signalRServiceRef.current.leaveClass(classSession.id) // sonra disconnect await signalRServiceRef.current.disconnect() } webRTCServiceRef.current?.closeAllConnections() } const handleToggleAudio = () => { setIsAudioEnabled(!isAudioEnabled) webRTCServiceRef.current?.toggleAudio(!isAudioEnabled) } const handleToggleVideo = () => { setIsVideoEnabled(!isVideoEnabled) webRTCServiceRef.current?.toggleVideo(!isVideoEnabled) } const handleLeaveCall = async () => { try { // Eğer teacher ise sınıfı kapat if (user.role === 'teacher' && user.id === classSession.teacherId) { await endVideoroom(classSession.id) } // Bağlantıları kapat await cleanup() // Başka sayfaya yönlendir navigate(ROUTES_ENUM.protected.admin.videoroom.roomList) } catch (err) { console.error('Çıkış sırasında hata oluştu:', err) toast.push(, { placement: 'bottom-end', }) navigate(ROUTES_ENUM.protected.admin.videoroom.roomList) } } const handleSendMessage = async (e: FormEvent) => { e.preventDefault() if (newMessage.trim() && signalRServiceRef.current) { if (messageMode === 'private' && selectedRecipient) { try { await signalRServiceRef.current.sendPrivateMessage( classSession.id, user.id, user.name, newMessage.trim(), selectedRecipient.id, selectedRecipient.name, user.role === 'teacher', ) } catch (error) { console.error('Özel mesaj gönderilemedi:', error) toast.push(, { placement: 'bottom-end', }) } } else if (messageMode === 'announcement' && user.role === 'teacher') { try { await signalRServiceRef.current.sendAnnouncement( classSession.id, user.id, user.name, newMessage.trim(), user.role === 'teacher', ) } catch (error) { console.error('Duyuru gönderilemedi:', error) toast.push(, { placement: 'bottom-end', }) } } else { try { await signalRServiceRef.current.sendChatMessage( classSession.id, user.id, user.name, newMessage.trim(), user.role === 'teacher', ) } catch (error) { console.error('Genel mesaj gönderilemedi:', error) toast.push(, { placement: 'bottom-end', }) } } setNewMessage('') } } const handleMuteParticipant = async ( participantId: string, isMuted: boolean, isTeacher: boolean, ) => { if (signalRServiceRef.current && user.role === 'teacher') { try { await signalRServiceRef.current.muteParticipant( classSession.id, participantId, isMuted, isTeacher, ) } catch (err) { console.error('Katılımcı susturulamadı:', err) toast.push(, { placement: 'bottom-end', }) } } } const handleMuteAll = async () => { if (signalRServiceRef.current && user.role === 'teacher') { const newMuteState = !isAllMuted setIsAllMuted(newMuteState) // Mute all participants except teacher for (const participant of participants) { if (!participant.isTeacher) { await signalRServiceRef.current.muteParticipant( classSession.id, participant.id, newMuteState, user.role === 'teacher', ) } } } } const handleRaiseHand = async () => { if ( signalRServiceRef.current && user.role === 'student' && !hasRaisedHand && classSettings.allowHandRaise ) { await signalRServiceRef.current.raiseHand(classSession.id, user.id, user.name) setHasRaisedHand(true) } } const handleKickParticipant = async (participantId: string, participantName: string) => { if (signalRServiceRef.current && user.role === 'teacher') { try { await signalRServiceRef.current.kickParticipant( classSession.id, participantId, participantName, ) setAttendanceRecords((prev) => prev.map((r) => { if (r.studentId === participantId && !r.leaveTime) { const leaveTime = new Date().toISOString() const join = new Date(r.joinTime) const leave = new Date(leaveTime) const totalDurationMinutes = Math.max( 1, Math.round((leave.getTime() - join.getTime()) / 60000), ) return { ...r, leaveTime, totalDurationMinutes } } return r }), ) } catch (error) { console.error('Katılımcı atılamadı:', error) toast.push(, { placement: 'bottom-end', }) } } } const handleUploadDocument = async (file: File) => { // In a real app, this would upload to a server const newDoc: VideoroomDocumentDto = { id: crypto.randomUUID(), name: file.name, url: URL.createObjectURL(file), type: file.type, size: file.size, uploadedAt: new Date().toISOString(), uploadedBy: user.name, } setDocuments((prev) => [...prev, newDoc]) } const handleDeleteDocument = (documentId: string) => { setDocuments((prev) => prev.filter((d) => d.id !== documentId)) } const handleViewDocument = (document: VideoroomDocumentDto) => { window.open(document.url, '_blank') } const handleStartScreenShare = async () => { try { // 1. sadece ekran videosu al const screen = await navigator.mediaDevices.getDisplayMedia({ video: true, }) // 2. mikrofonu ayrı al let mic: MediaStream | null = null try { mic = await navigator.mediaDevices.getUserMedia({ audio: true }) } catch (err) { console.error('Mikrofon alınamadı:', err) toast.push( , ) } // 3. merge et if (mic) { mic.getAudioTracks().forEach((track) => screen.addTrack(track)) } setScreenStream(screen) setIsScreenSharing(true) setScreenSharer(user.name) webRTCServiceRef.current?.addStreamToPeers(screen) // Handle stream end screen.getVideoTracks()[0].onended = () => { handleStopScreenShare() } } catch (error) { console.error('Ekran paylaşımı başlatılamadı:', error) toast.push(, { placement: 'bottom-end', }) } } const handleStopScreenShare = () => { if (screenStream) { // PeerConnections’tan kaldır screenStream.getTracks().forEach((track) => { webRTCServiceRef.current?.removeTrackFromPeers(track) track.stop() }) setScreenStream(undefined) } setIsScreenSharing(false) setScreenSharer(undefined) } const handleLayoutChange = (layout: VideoroomLayoutDto) => { setCurrentLayout(layout) if (layout.type === 'grid') { setFocusedParticipant(undefined) } } const handleParticipantFocus = (participantId: string | undefined) => { setFocusedParticipant(participantId) } const toggleFullscreen = () => { if (!document.fullscreenElement) { document.documentElement.requestFullscreen() setIsFullscreen(true) } else { document.exitFullscreen() setIsFullscreen(false) } } const toggleSidePanel = (panelType: SidePanelType) => { setActiveSidePanel(activeSidePanel === panelType ? null : panelType) } const formatTime = (timestamp: string) => { return new Date(timestamp).toLocaleTimeString('tr-TR', { hour: '2-digit', minute: '2-digit', }) } const formatDuration = (minutes: number) => { const hours = Math.floor(minutes / 60) const mins = minutes % 60 if (hours > 0) { return `${hours}h ${mins}m` } return `${mins}m` } const getFileIcon = (type: string) => { if (type.includes('pdf')) return if ( type.includes('word') || type.includes('doc') || type.includes('presentation') || type.includes('powerpoint') ) return if (type.includes('image')) return return } const formatFileSize = (bytes: number) => { if (bytes === 0) return '0 Bytes' const k = 1024 const sizes = ['Bytes', 'KB', 'MB', 'GB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] } // ...existing code... // ...existing code... // --- yan panel fonksiyonu --- const renderSidePanel = () => { if (!activeSidePanel) return null switch (activeSidePanel) { case 'chat': return ( setActiveSidePanel(null)} formatTime={formatTime} classSettings={classSettings} /> ) case 'participants': return ( setActiveSidePanel(null)} formatTime={formatTime} formatDuration={formatDuration} /> ) // ...existing code... case 'documents': return ( setActiveSidePanel(null)} formatFileSize={formatFileSize} getFileIcon={getFileIcon} /> ) case 'layout': return ( setActiveSidePanel(null)} /> ) default: return null } } useEffect(() => { window.addEventListener('beforeunload', handleLeaveCall) window.addEventListener('pagehide', handleLeaveCall) return () => { window.removeEventListener('beforeunload', handleLeaveCall) window.removeEventListener('pagehide', handleLeaveCall) } }, [classSession.id]) return ( <> {teacherDisconnected && (
Öğretmenin bağlantısı koptu, tekrar bağlanmasını bekleyin...
)}
{/* Main Content Area */}
{/* Left Content Area - Video and Screen Share */}
{/* Video Container - Panel kapalıyken ortalanmış */}
{/* Screen Share Panel */} {(isScreenSharing || screenStream) && (
)} {/* Video Grid */}
p.isActive)} localStream={user.role === 'observer' ? undefined : localStream} currentUserId={user.id} currentUserName={user.name} isTeacher={user.role === 'teacher'} isAudioEnabled={isAudioEnabled} isVideoEnabled={isVideoEnabled} onMuteParticipant={handleMuteParticipant} layout={currentLayout ?? layouts[0]} focusedParticipant={focusedParticipant} onParticipantFocus={handleParticipantFocus} hasSidePanel={!!activeSidePanel} onKickParticipant={ user.role === 'teacher' ? (participantId) => { const participant = participants.find((p) => p.id === participantId) if (participant) { setKickingParticipant({ id: participant.id, name: participant.name }) } } : undefined } />
{/* Side Panel */} {activeSidePanel && (
{renderSidePanel()}
)}
{/* Bottom Control Bar - Google Meet Style */}
{/* Mobile Layout */}
{/* Left Side - Main Controls */}
{/* Audio Control */} {user.role !== 'observer' && ( )} {/* Video Control */} {user.role !== 'observer' && ( )} {/* Screen Share */} {(user.role === 'teacher' || classSettings.allowStudentScreenShare) && ( )} {/* Hand Raise (Students) */} {user.role === 'student' && classSettings.allowHandRaise && ( )} {/* Leave Call */}
{/* Right Side - Panel Controls */}
{/* Hamburger Menu Modal */} {mobileMenuOpen && ( <> {/* Overlay */}
setMobileMenuOpen(false)} /> {/* Drawer */}
Menü
{user.role === 'teacher' && ( )}
)}
{/* Desktop Layout */}
{/* Left Side - Meeting Info */}
{classSession?.name}
{new Date().toLocaleTimeString('tr-TR', { hour: '2-digit', minute: '2-digit' })}
{/* Center - Main Controls */}
{/* Audio Control */} {user.role !== 'observer' && ( )} {/* Video Control */} {user.role !== 'observer' && ( )} {/* Screen Share */} {(user.role === 'teacher' || classSettings.allowStudentScreenShare) && ( )} {/* Hand Raise (Students) */} {user.role === 'student' && classSettings.allowHandRaise && ( )} {/* Leave Call */}
{/* Right Side - Panel Controls */}
{/* Fullscreen Toggle */} {/* Chat */} {((user.role !== 'observer' && classSettings.allowStudentChat) || user.role === 'teacher') && ( )} {/* Participants */} {/* Teacher Only Options */} {user.role === 'teacher' && ( <> {/* Documents Button */} {/* Mute All Button */} )} {/* Layout Button */}
{/* Kick Participant Modal */} setKickingParticipant(null)} onConfirm={handleKickParticipant} />
) } export default RoomDetail