sozsoft-platform/ui/src/views/admin/videoroom/RoomDetail.tsx
2026-10-17 21:51:10 +03:00

1382 lines
52 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 { useState, useEffect, useRef, useMemo, useCallback, 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 { formatLocaleTime } from '@/utils/localeFormat'
import { useNavigate, useParams } from 'react-router-dom'
import { ROUTES_ENUM } from '@/routes/route.constant'
import PageTitle from '@/components/shared/PageTitle'
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 { Button } from '@/components/ui'
import {
VideoroomDocumentDto,
VideoroomAttendanceDto,
VideoroomChatDto,
VideoroomDto,
VideoroomParticipantDto,
VideoroomSettingsDto,
MessageType,
VideoroomLayoutDto,
} from '@/proxy/videoroom/models'
import { createId } from '@/utils/id'
import { notifyDanger, notifySuccess, notifyWarning } from '@/utils/notify'
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<VideoroomDto>(newClassSession)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [participants, setParticipants] = useState<VideoroomParticipantDto[]>([])
const [localStream, setLocalStream] = useState<MediaStream | null>(null)
const [isAudioEnabled, setIsAudioEnabled] = useState(true)
const [isVideoEnabled, setIsVideoEnabled] = useState(true)
const [attendanceRecords, setAttendanceRecords] = useState<VideoroomAttendanceDto[]>([])
const [chatMessages, setChatMessages] = useState<VideoroomChatDto[]>([])
const [currentLayout, setCurrentLayout] = useState<VideoroomLayoutDto | null>(null)
const [focusedParticipant, setFocusedParticipant] = useState<string>()
const [hasRaisedHand, setHasRaisedHand] = useState(false)
const [isAllMuted, setIsAllMuted] = useState(false)
const [kickingParticipant, setKickingParticipant] = useState<{ id: string; name: string } | null>(
null,
)
const [documents, setDocuments] = useState<VideoroomDocumentDto[]>([])
const [isScreenSharing, setIsScreenSharing] = useState(false)
const [screenStream, setScreenStream] = useState<MediaStream>()
const [screenSharer, setScreenSharer] = useState<string>()
const [isFullscreen, setIsFullscreen] = useState(false)
const [activeSidePanel, setActiveSidePanel] = useState<SidePanelType>(null)
const [newMessage, setNewMessage] = useState('')
const [messageMode, setMessageMode] = useState<MessageType>('public')
const [selectedRecipient, setSelectedRecipient] = useState<{ id: string; name: string } | null>(
null,
)
const raisedHandsCount = participants.filter((p) => p.isHandRaised).length
const messagesEndRef = useRef<HTMLDivElement>(null)
const [classSettings, setClassSettings] = useState<VideoroomSettingsDto>({
allowHandRaise: true,
defaultMicrophoneState: 'muted',
defaultCameraState: 'on',
defaultLayout: 'grid',
allowStudentScreenShare: false,
allowStudentChat: true,
allowPrivateMessages: true,
autoMuteNewParticipants: true,
})
const signalRServiceRef = useRef<SignalRService | undefined>(undefined)
const webRTCServiceRef = useRef<WebRTCService | undefined>(undefined)
const [teacherDisconnected, setTeacherDisconnected] = useState(false)
const layouts = useMemo<VideoroomLayoutDto[]>(
() => [
{
id: 'grid',
name: translate('::App.VideoRoom.LayoutGridView'),
type: 'grid',
description: translate('::App.VideoRoom.LayoutGridViewDescription'),
},
{
id: 'sidebar',
name: translate('::App.VideoRoom.LayoutPresentation'),
type: 'sidebar',
description: translate('::App.VideoRoom.LayoutPresentationDescription'),
},
{
id: 'teacher-focus',
name: translate('::App.VideoRoom.LayoutTeacherFocus'),
type: 'teacher-focus',
description: translate('::App.VideoRoom.LayoutTeacherFocusDescription'),
},
],
[translate],
)
const roomId = params?.id
const fetchClassAttendances = useCallback(async () => {
if (!roomId) return
const attResult = await getVideoroomAttandances(roomId)
if (attResult && attResult.data) {
setAttendanceRecords(attResult.data)
}
}, [roomId])
const fetchClassChats = useCallback(async () => {
if (!roomId) return
const chatResult = await getVideoroomChats(roomId)
if (chatResult && chatResult.data) {
setChatMessages(chatResult.data || [])
}
}, [roomId])
const fetchClassDetails = useCallback(async () => {
const classEntity = await getVideoroomById(roomId ?? '')
if (classEntity) {
setClassSession(classEntity.data)
}
}, [roomId])
useEffect(() => {
fetchClassDetails()
fetchClassChats()
fetchClassAttendances()
}, [fetchClassDetails, fetchClassChats, fetchClassAttendances])
useEffect(() => {
if (classSession.id) {
initializeServices()
return () => {
cleanup()
}
}
// oxlint-disable-next-line react-hooks/exhaustive-deps -- connect once per class session; settings updates must not tear down SignalR/WebRTC
}, [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, layouts])
useEffect(() => {
scrollToBottom()
}, [chatMessages])
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
const initializeServices = async () => {
try {
// Initialize SignalR
signalRServiceRef.current = new SignalRService()
signalRServiceRef.current.setTranslate(translate)
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.setTranslate(translate)
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
notifySuccess(translate('::App.VideoRoom.ParticipantJoined', { name }))
// 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) {
notifyWarning(
translate('::App.VideoRoom.ParticipantLeft', {
name: userName ?? translate('::App.VideoRoom.Unknown'),
}),
)
}
// 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)
notifyDanger(translate('::App.VideoRoom.ServicesStartFailed'))
}
}
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.list)
} catch (err) {
console.error('Çıkış sırasında hata oluştu:', err)
notifyWarning(translate('::App.VideoRoom.LeaveFailed'))
navigate(ROUTES_ENUM.protected.admin.videoroom.list)
}
}
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)
notifyDanger(translate('::App.Platform.PrivateMessageFailed'))
}
} 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)
notifyDanger(translate('::App.Platform.AnnouncementFailed'))
}
} 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)
notifyDanger(translate('::App.Platform.PublicMessageFailed'))
}
}
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)
notifyDanger(translate('::App.Platform.MuteFailed'))
}
}
}
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)
notifyDanger(translate('::App.Platform.KickFailed'))
}
}
}
const handleUploadDocument = async (file: File) => {
// In a real app, this would upload to a server
const newDoc: VideoroomDocumentDto = {
id: createId(),
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)
notifyWarning(translate('::App.VideoRoom.MicrophoneUnavailable'))
}
// 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)
notifyDanger(translate('::App.VideoRoom.ScreenShareFailed'))
}
}
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) => formatLocaleTime(timestamp)
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 <FaFilePdf className="text-red-500" />
if (
type.includes('word') ||
type.includes('doc') ||
type.includes('presentation') ||
type.includes('powerpoint')
)
return <FaFileWord className="text-blue-500" />
if (type.includes('image')) return <FaFileImage className="text-green-500" />
return <FaFileAlt className="text-gray-500" />
}
// ...existing code...
// ...existing code...
// --- yan panel fonksiyonu ---
const renderSidePanel = () => {
if (!activeSidePanel) return null
switch (activeSidePanel) {
case 'chat':
return (
<ChatPanel
user={user}
participants={participants}
chatMessages={chatMessages}
newMessage={newMessage}
setNewMessage={setNewMessage}
messageMode={messageMode}
setMessageMode={setMessageMode}
selectedRecipient={selectedRecipient}
setSelectedRecipient={setSelectedRecipient}
onSendMessage={handleSendMessage}
onClose={() => setActiveSidePanel(null)}
formatTime={formatTime}
classSettings={classSettings}
/>
)
case 'participants':
return (
<ParticipantsPanel
user={user}
participants={participants}
attendanceRecords={attendanceRecords}
onMuteParticipant={handleMuteParticipant}
onKickParticipant={handleKickParticipant}
onDismissHandRaise={handleDismissHandRaise}
onClose={() => setActiveSidePanel(null)}
formatTime={formatTime}
formatDuration={formatDuration}
/>
)
// ...existing code...
case 'documents':
return (
<DocumentsPanel
user={user}
documents={documents}
onUpload={handleUploadDocument}
onDelete={handleDeleteDocument}
onView={handleViewDocument}
onClose={() => setActiveSidePanel(null)}
getFileIcon={getFileIcon}
/>
)
case 'layout':
return (
<LayoutPanel
layouts={layouts}
currentLayout={currentLayout ?? layouts[0]}
onChangeLayout={handleLayoutChange}
onClose={() => setActiveSidePanel(null)}
/>
)
default:
return null
}
}
useEffect(() => {
window.addEventListener('beforeunload', handleLeaveCall)
window.addEventListener('pagehide', handleLeaveCall)
return () => {
window.removeEventListener('beforeunload', handleLeaveCall)
window.removeEventListener('pagehide', handleLeaveCall)
}
// oxlint-disable-next-line react-hooks/exhaustive-deps -- listeners are bound per class session, matching the connection lifecycle above
}, [classSession.id])
return (
<>
<PageTitle appName="Erp Platform" title={translate('::' + 'App.Videoroom.Detail')} />
{teacherDisconnected && (
<div className="bg-red-600 text-white p-2 text-center">
{translate('::App.VideoRoom.TeacherDisconnected')}
</div>
)}
<div className="min-h-screen h-screen flex flex-col bg-gray-900 text-white overflow-hidden">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="h-full flex flex-col relative overflow-hidden"
>
{/* Main Content Area */}
<div className="flex-1 flex flex-col lg:flex-row relative min-h-0 h-full">
{/* Left Content Area - Video and Screen Share */}
<div
className={`flex-1 flex flex-col min-h-0 h-full transition-all duration-300 ${!activeSidePanel ? 'flex items-center justify-center' : ''}`}
>
{/* Video Container - Panel kapalıyken ortalanmış */}
<div
className={`${!activeSidePanel ? 'w-full max-w-6xl' : 'w-full h-full'} flex flex-col min-h-0 h-full`}
>
{/* Screen Share Panel */}
{(isScreenSharing || screenStream) && (
<div className="p-2 sm:p-4 shrink-0">
<ScreenSharePanel
isSharing={isScreenSharing}
onStartShare={handleStartScreenShare}
onStopShare={handleStopScreenShare}
sharedScreen={screenStream}
sharerName={screenSharer}
/>
</div>
)}
{/* Video Grid */}
<div className="flex-1 relative overflow-hidden min-h-0 h-full">
<RoomParticipant
participants={participants.filter((p) => 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
}
/>
</div>
</div>
</div>
{/* Side Panel */}
{activeSidePanel && (
<div className="fixed inset-0 z-40 bg-white flex flex-col w-full h-full lg:relative lg:inset-auto lg:w-80 lg:h-full lg:z-0 lg:bg-white">
{renderSidePanel()}
</div>
)}
</div>
{/* Bottom Control Bar - Google Meet Style */}
<div className="bg-gray-800 p-2 sm:p-3 shrink-0">
{/* Mobile Layout */}
<div className="flex lg:hidden items-center justify-between">
{/* Left Side - Main Controls */}
<div className="flex items-center space-x-1 sm:space-x-2">
{/* Audio Control */}
{user.role !== 'observer' && (
<Button
type="button"
variant="plain"
shape="circle"
onClick={handleToggleAudio}
className={`inline-flex! h-auto! items-center justify-center rounded-full p-2! text-white transition-all sm:p-3! ${
isAudioEnabled
? 'bg-gray-700! hover:bg-gray-600!'
: 'bg-red-600! hover:bg-red-700!'
}`}
title={translate(
isAudioEnabled
? '::App.VideoRoom.TurnOffMicrophone'
: '::App.VideoRoom.TurnOnMicrophone',
)}
>
{isAudioEnabled ? <FaMicrophone size={16} /> : <FaMicrophoneSlash size={16} />}
</Button>
)}
{/* Video Control */}
{user.role !== 'observer' && (
<Button
type="button"
variant="plain"
shape="circle"
onClick={handleToggleVideo}
className={`inline-flex! h-auto! items-center justify-center rounded-full p-2! text-white transition-all sm:p-3! ${
isVideoEnabled
? 'bg-gray-700! hover:bg-gray-600!'
: 'bg-red-600! hover:bg-red-700!'
}`}
title={translate(
isVideoEnabled
? '::App.VideoRoom.TurnOffCamera'
: '::App.VideoRoom.TurnOnCamera',
)}
>
{isVideoEnabled ? <FaVideo size={16} /> : <FaVideoSlash size={16} />}
</Button>
)}
{/* Screen Share */}
{(user.role === 'teacher' || classSettings.allowStudentScreenShare) && (
<Button
type="button"
variant="plain"
shape="circle"
onClick={isScreenSharing ? handleStopScreenShare : handleStartScreenShare}
className={`inline-flex! h-auto! items-center justify-center rounded-full p-2! text-white transition-all sm:p-3! ${
isScreenSharing
? 'bg-blue-600! hover:bg-blue-700!'
: 'bg-gray-700! hover:bg-gray-600!'
}`}
title={translate(
isScreenSharing
? '::App.VideoRoom.StopSharing'
: '::App.VideoRoom.ShareScreen',
)}
>
<FaDesktop size={16} />
</Button>
)}
{/* Hand Raise (Students) */}
{user.role === 'student' && classSettings.allowHandRaise && (
<Button
type="button"
variant="plain"
shape="circle"
onClick={handleRaiseHand}
disabled={hasRaisedHand}
className={`inline-flex! h-auto! items-center justify-center rounded-full p-2! text-white transition-all sm:p-3! ${
hasRaisedHand
? 'bg-yellow-600! cursor-not-allowed'
: 'bg-gray-700! hover:bg-yellow-600!'
}`}
title={translate(
hasRaisedHand ? '::App.VideoRoom.HandRaised' : '::App.VideoRoom.RaiseHand',
)}
>
<FaHandPaper size={16} />
</Button>
)}
{/* Leave Call */}
<Button
type="button"
variant="plain"
shape="circle"
onClick={handleLeaveCall}
className="inline-flex! h-auto! items-center justify-center rounded-full bg-red-600! p-2! text-white transition-all hover:bg-red-700! sm:p-3!"
title={translate('::App.VideoRoom.EndCall')}
>
<FaPhone size={16} />
</Button>
</div>
{/* Right Side - Panel Controls */}
<div className="flex items-center">
<Button
type="button"
variant="plain"
shape="none"
className="inline-flex! h-auto! items-center justify-center rounded-lg bg-gray-700! p-2! text-white hover:bg-gray-600!"
onClick={() => setMobileMenuOpen(true)}
aria-label={translate('::App.VideoRoom.OpenMenu')}
>
<FaBars size={20} />
</Button>
</div>
{/* Hamburger Menu Modal */}
{mobileMenuOpen && (
<>
{/* Overlay */}
<div
className="fixed inset-0 z-40 bg-black/40"
onClick={() => setMobileMenuOpen(false)}
/>
{/* Drawer */}
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="fixed inset-0 z-50 w-full h-full bg-white shadow-2xl flex flex-col p-0 lg:top-0 lg:right-0 lg:w-80 lg:h-full lg:inset-y-0 lg:left-auto"
>
<div className="flex items-center justify-between px-4 py-4 border-b">
<span className="font-semibold text-gray-800 text-lg">
{translate('::App.VideoRoom.Menu')}
</span>
<Button
type="button"
variant="plain"
shape="none"
onClick={() => setMobileMenuOpen(false)}
className="inline-flex! h-auto! items-center justify-center rounded-full bg-transparent! p-2! hover:bg-gray-100!"
>
<FaTimes size={22} />
</Button>
</div>
<div className="flex-1 flex flex-col space-y-1 px-2 py-2 overflow-y-auto">
<Button
type="button"
variant="plain"
shape="none"
onClick={() => {
setMobileMenuOpen(false)
setTimeout(() => toggleSidePanel('chat'), 200)
}}
className={`inline-flex! h-auto! items-center gap-2 rounded-lg p-3! text-base transition-all ${activeSidePanel === 'chat' ? 'bg-blue-100! text-blue-700' : 'bg-transparent! text-gray-700 hover:bg-gray-100!'}`}
>
<FaComments /> <span>{translate('::App.Platform.SohbetAnswer')}</span>
{chatMessages.length > 0 && (
<span className="ml-auto bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{chatMessages.length > 9 ? '9+' : chatMessages.length}
</span>
)}
</Button>
<Button
type="button"
variant="plain"
shape="none"
onClick={() => {
setMobileMenuOpen(false)
setTimeout(() => toggleSidePanel('participants'), 200)
}}
className={`inline-flex! h-auto! items-center gap-2 rounded-lg p-3! text-base transition-all relative ${activeSidePanel === 'participants' ? 'bg-blue-100! text-blue-700' : 'bg-transparent! text-gray-700 hover:bg-gray-100!'}`}
>
<FaUserFriends />
<span>{translate('::App.Listform.ListformField.Participants')}</span>
{/* El kaldıran badge */}
{raisedHandsCount > 0 && (
<span className="absolute top-2 right-3 bg-yellow-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px]">
{raisedHandsCount > 9 ? '9+' : raisedHandsCount}
</span>
)}
</Button>
<Button
type="button"
variant="plain"
shape="none"
onClick={() => {
setMobileMenuOpen(false)
setTimeout(() => toggleSidePanel('documents'), 200)
}}
className={`inline-flex! h-auto! items-center gap-2 rounded-lg p-3! text-base transition-all ${activeSidePanel === 'documents' ? 'bg-blue-100! text-blue-700' : 'bg-transparent! text-gray-700 hover:bg-gray-100!'}`}
>
<FaFile /> <span>{translate('::App.VideoRoom.Documents')}</span>
</Button>
<Button
type="button"
variant="plain"
shape="none"
onClick={() => {
setMobileMenuOpen(false)
setTimeout(() => toggleSidePanel('layout'), 200)
}}
className={`inline-flex! h-auto! items-center gap-2 rounded-lg p-3! text-base transition-all ${activeSidePanel === 'layout' ? 'bg-blue-100! text-blue-700' : 'bg-transparent! text-gray-700 hover:bg-gray-100!'}`}
>
<FaLayerGroup /> <span>{translate('::App.StaticLookup.View')}</span>
</Button>
{user.role === 'teacher' && (
<Button
type="button"
variant="plain"
shape="none"
onClick={() => {
setMobileMenuOpen(false)
setTimeout(() => handleMuteAll(), 200)
}}
className="inline-flex! h-auto! items-center gap-2 rounded-lg bg-transparent! p-3! text-base text-gray-700 transition-all hover:bg-gray-100!"
>
{isAllMuted ? <FaVolumeUp /> : <FaVolumeMute />}{' '}
<span>
{translate(
isAllMuted ? '::App.VideoRoom.UnmuteAll' : '::App.VideoRoom.MuteAll',
)}
</span>
</Button>
)}
<Button
type="button"
variant="plain"
shape="none"
onClick={() => {
setMobileMenuOpen(false)
setTimeout(() => toggleFullscreen(), 200)
}}
className="inline-flex! h-auto! items-center gap-2 rounded-lg bg-transparent! p-3! text-base text-gray-700 transition-all hover:bg-gray-100!"
>
{isFullscreen ? <FaCompress /> : <FaExpand />}{' '}
<span>
{translate(
isFullscreen
? '::App.VideoRoom.ExitFullscreen'
: '::App.Platform.Fullscreen',
)}
</span>
</Button>
</div>
</motion.div>
</>
)}
</div>
{/* Desktop Layout */}
<div className="hidden lg:flex items-center justify-center relative">
{/* Left Side - Meeting Info */}
<div className="flex items-center space-x-4 text-white absolute left-0">
<div className="flex items-center space-x-2">
<span className="text-sm font-medium truncate">{classSession?.name}</span>
<div className="w-px h-4 bg-gray-600"></div>
<span className="text-sm text-gray-300">{formatLocaleTime(new Date())}</span>
</div>
</div>
{/* Center - Main Controls */}
<div className="flex items-center space-x-2">
{/* Audio Control */}
{user.role !== 'observer' && (
<Button
type="button"
variant="plain"
shape="circle"
onClick={handleToggleAudio}
className={`inline-flex! h-auto! items-center justify-center rounded-full p-3! text-white transition-all ${
isAudioEnabled
? 'bg-gray-700! hover:bg-gray-600!'
: 'bg-red-600! hover:bg-red-700!'
}`}
title={translate(
isAudioEnabled
? '::App.VideoRoom.TurnOffMicrophone'
: '::App.VideoRoom.TurnOnMicrophone',
)}
>
{isAudioEnabled ? <FaMicrophone size={16} /> : <FaMicrophoneSlash size={16} />}
</Button>
)}
{/* Video Control */}
{user.role !== 'observer' && (
<Button
type="button"
variant="plain"
shape="circle"
onClick={handleToggleVideo}
className={`inline-flex! h-auto! items-center justify-center rounded-full p-3! text-white transition-all ${
isVideoEnabled
? 'bg-gray-700! hover:bg-gray-600!'
: 'bg-red-600! hover:bg-red-700!'
}`}
title={translate(
isVideoEnabled
? '::App.VideoRoom.TurnOffCamera'
: '::App.VideoRoom.TurnOnCamera',
)}
>
{isVideoEnabled ? <FaVideo size={16} /> : <FaVideoSlash size={16} />}
</Button>
)}
{/* Screen Share */}
{(user.role === 'teacher' || classSettings.allowStudentScreenShare) && (
<Button
type="button"
variant="plain"
shape="circle"
onClick={isScreenSharing ? handleStopScreenShare : handleStartScreenShare}
className={`inline-flex! h-auto! items-center justify-center rounded-full p-3! text-white transition-all ${
isScreenSharing
? 'bg-blue-600! hover:bg-blue-700!'
: 'bg-gray-700! hover:bg-gray-600!'
}`}
title={translate(
isScreenSharing
? '::App.VideoRoom.StopSharing'
: '::App.VideoRoom.ShareScreen',
)}
>
<FaDesktop size={16} />
</Button>
)}
{/* Hand Raise (Students) */}
{user.role === 'student' && classSettings.allowHandRaise && (
<Button
type="button"
variant="plain"
shape="circle"
onClick={handleRaiseHand}
disabled={hasRaisedHand}
className={`inline-flex! h-auto! items-center justify-center rounded-full p-3! text-white transition-all ${
hasRaisedHand
? 'bg-yellow-600! cursor-not-allowed'
: 'bg-gray-700! hover:bg-yellow-600!'
}`}
title={translate(
hasRaisedHand ? '::App.VideoRoom.HandRaised' : '::App.VideoRoom.RaiseHand',
)}
>
<FaHandPaper size={16} />
</Button>
)}
{/* Leave Call */}
<Button
type="button"
variant="plain"
shape="circle"
onClick={handleLeaveCall}
className="inline-flex! h-auto! items-center justify-center rounded-full bg-red-600! p-3! text-white transition-all hover:bg-red-700!"
title={translate('::App.VideoRoom.EndCall')}
>
<FaPhone size={16} />
</Button>
</div>
{/* Right Side - Panel Controls */}
<div className="flex items-center space-x-2 absolute right-0">
{/* Fullscreen Toggle */}
<Button
type="button"
variant="plain"
shape="none"
onClick={toggleFullscreen}
className="inline-flex! h-auto! items-center justify-center rounded-lg bg-gray-700! p-2! text-white transition-all hover:bg-gray-600!"
title={translate(
isFullscreen ? '::App.VideoRoom.ExitFullscreen' : '::App.Platform.Fullscreen',
)}
>
{isFullscreen ? <FaCompress size={14} /> : <FaExpand size={14} />}
</Button>
{/* Chat */}
{((user.role !== 'observer' && classSettings.allowStudentChat) ||
user.role === 'teacher') && (
<Button
type="button"
variant="plain"
shape="none"
onClick={() => toggleSidePanel('chat')}
className={`inline-flex! h-auto! items-center justify-center rounded-lg p-2! transition-all relative ${
activeSidePanel === 'chat'
? 'bg-blue-600! text-white'
: 'bg-gray-700! hover:bg-gray-600! text-white'
}`}
title={translate('::App.Platform.SohbetAnswer')}
>
<FaComments size={14} />
{chatMessages.length > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px]">
{chatMessages.length > 9 ? '9+' : chatMessages.length}
</span>
)}
</Button>
)}
{/* Participants */}
<Button
type="button"
variant="plain"
shape="none"
onClick={() => toggleSidePanel('participants')}
className={`inline-flex! h-auto! items-center justify-center rounded-lg p-2! transition-all relative ${
activeSidePanel === 'participants'
? 'bg-blue-600! text-white'
: 'bg-gray-700! hover:bg-gray-600! text-white'
}`}
title={translate('::App.Listform.ListformField.Participants')}
>
<FaUserFriends size={14} />
{/* El kaldıran badge */}
{raisedHandsCount > 0 && (
<span className="absolute -top-1 -right-3 bg-yellow-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px]">
{raisedHandsCount > 9 ? '9+' : raisedHandsCount}
</span>
)}
</Button>
{/* Teacher Only Options */}
{user.role === 'teacher' && (
<>
{/* Documents Button */}
<Button
type="button"
variant="plain"
shape="none"
onClick={() => toggleSidePanel('documents')}
className={`inline-flex! h-auto! items-center justify-center rounded-lg p-2! transition-all ${
activeSidePanel === 'documents'
? 'bg-blue-600! text-white'
: 'bg-gray-700! hover:bg-gray-600! text-white'
}`}
title={translate('::App.VideoRoom.Documents')}
>
<FaFile size={14} />
</Button>
{/* Mute All Button */}
<Button
type="button"
variant="plain"
shape="none"
onClick={handleMuteAll}
className="inline-flex! h-auto! items-center justify-center rounded-lg bg-gray-700! p-2! text-white transition-all hover:bg-gray-600!"
title={translate(
isAllMuted ? '::App.VideoRoom.UnmuteAll' : '::App.VideoRoom.MuteAll',
)}
>
{isAllMuted ? <FaVolumeUp size={14} /> : <FaVolumeMute size={14} />}
</Button>
</>
)}
{/* Layout Button */}
<Button
type="button"
variant="plain"
shape="none"
onClick={() => toggleSidePanel('layout')}
className={`inline-flex! h-auto! items-center justify-center rounded-lg p-2! transition-all ${
activeSidePanel === 'layout'
? 'bg-blue-600! text-white'
: 'bg-gray-700! hover:bg-gray-600! text-white'
}`}
title="Layout"
>
<FaLayerGroup size={14} />
</Button>
</div>
</div>
</div>
{/* Kick Participant Modal */}
<KickParticipantModal
participant={kickingParticipant}
isOpen={!!kickingParticipant}
onClose={() => setKickingParticipant(null)}
onConfirm={handleKickParticipant}
/>
</motion.div>
</div>
</>
)
}
export default RoomDetail