diff --git a/api/src/Kurs.Platform.HttpApi.Host/Classroom/ClassroomHub.cs b/api/src/Kurs.Platform.HttpApi.Host/Classroom/ClassroomHub.cs index 3a9bc5f4..70f0a8e8 100644 --- a/api/src/Kurs.Platform.HttpApi.Host/Classroom/ClassroomHub.cs +++ b/api/src/Kurs.Platform.HttpApi.Host/Classroom/ClassroomHub.cs @@ -41,6 +41,79 @@ public class ClassroomHub : Hub _currentUser = currentUser; } + #region Helper Methods + private async Task CreateAttendanceAsync(Guid sessionId, Guid userId, string userName) + { + var attendance = new ClassroomAttandance( + _guidGenerator.Create(), + sessionId, + userId, + userName, + DateTime.UtcNow + ); + await _attendanceRepository.InsertAsync(attendance, autoSave: true); + return attendance; + } + + private async Task CloseAttendanceAsync(ClassroomAttandance attendance) + { + attendance.LeaveTime = DateTime.UtcNow; + attendance.TotalDurationMinutes = (int)Math.Max( + 1, + (attendance.LeaveTime.Value - attendance.JoinTime).TotalMinutes + ); + await _attendanceRepository.UpdateAsync(attendance, autoSave: true); + } + + private async Task UpdateParticipantConnectionAsync(ClassroomParticipant participant, string connectionId, bool isActive) + { + participant.UpdateConnectionId(connectionId); + participant.IsActive = isActive; + await _participantRepository.UpdateAsync(participant, autoSave: true); + } + + private async Task DeactivateParticipantAsync(ClassroomParticipant participant) + { + participant.IsActive = false; + participant.ConnectionId = null; + await _participantRepository.UpdateAsync(participant, autoSave: true); + } + + private async Task UpdateParticipantCountAsync(Guid sessionId, Classroom classroom) + { + var participantCount = await _participantRepository.CountAsync(x => x.SessionId == sessionId); + classroom.ParticipantCount = participantCount; + await _classSessionRepository.UpdateAsync(classroom, autoSave: true); + } + + private async Task BroadcastChatMessageAsync(ClassroomChat chatMessage, Guid sessionId) + { + await _chatMessageRepository.InsertAsync(chatMessage, autoSave: true); + + await Clients.Group(sessionId.ToString()).SendAsync("ChatMessage", new + { + Id = chatMessage.Id, + chatMessage.SenderId, + chatMessage.SenderName, + chatMessage.Message, + chatMessage.Timestamp, + chatMessage.IsTeacher, + chatMessage.MessageType, + chatMessage.RecipientId, + chatMessage.RecipientName + }); + } + + private async Task SetMuteStateAsync(ClassroomParticipant participant, bool isMuted) + { + if (isMuted) participant.MuteAudio(); + else participant.UnmuteAudio(); + + await _participantRepository.UpdateAsync(participant, autoSave: true); + } + + #endregion + [HubMethodName("JoinClass")] public async Task JoinClassAsync(Guid sessionId, Guid userId, string userName, bool isTeacher, bool isActive) { @@ -52,7 +125,7 @@ public class ClassroomHub : Hub } var classroomSettings = string.IsNullOrWhiteSpace(classroom.SettingsJson) - ? new ClassroomSettingsDto() // default ayarlar + ? new ClassroomSettingsDto() : JsonSerializer.Deserialize(classroom.SettingsJson); var participant = await _participantRepository.FirstOrDefaultAsync( @@ -72,34 +145,19 @@ public class ClassroomHub : Hub false, isActive ); - participant.UpdateConnectionId(Context.ConnectionId); await _participantRepository.InsertAsync(participant, autoSave: true); - - // 🔑 Katılımcı sayısını güncelle - var participantCount = await _participantRepository.CountAsync(x => x.SessionId == sessionId); - classroom.ParticipantCount = participantCount; - await _classSessionRepository.UpdateAsync(classroom, autoSave: true); + await UpdateParticipantConnectionAsync(participant, Context.ConnectionId, isActive); + await UpdateParticipantCountAsync(sessionId, classroom); } else { - participant.UpdateConnectionId(Context.ConnectionId); - participant.IsActive = isActive; // Aktiflik durumunu güncelle - await _participantRepository.UpdateAsync(participant, autoSave: true); + await UpdateParticipantConnectionAsync(participant, Context.ConnectionId, isActive); } - // 🔑 Attendance kaydı aç - var attendance = new ClassroomAttandance( - _guidGenerator.Create(), - sessionId, - userId, - userName, - DateTime.UtcNow - ); - await _attendanceRepository.InsertAsync(attendance, autoSave: true); + await CreateAttendanceAsync(sessionId, userId, userName); await Groups.AddToGroupAsync(Context.ConnectionId, sessionId.ToString()); - // 🔑 Yeni katılana mevcut aktif katılımcıları gönder var existingParticipants = await _participantRepository.GetListAsync( x => x.SessionId == sessionId && x.IsActive ); @@ -111,58 +169,45 @@ public class ClassroomHub : Hub UserId = x.UserId, UserName = x.UserName, IsTeacher = x.IsTeacher, - IsActive = x.IsActive // ✅ aktiflik bilgisini de gönder + IsActive = x.IsActive }) .ToList(); await Clients.Caller.SendAsync("ExistingParticipants", others); - - // 🔑 Grup üyelerine yeni katılanı öğretmen bilgisiyle bildir await Clients.Group(sessionId.ToString()) .SendAsync("ParticipantJoined", userId, userName, isTeacher, isActive); } - [HubMethodName("LeaveClass")] public async Task LeaveClassAsync(Guid sessionId) { await Groups.RemoveFromGroupAsync(Context.ConnectionId, sessionId.ToString()); var userId = _currentUser.Id; - if (userId.HasValue) + if (!userId.HasValue) return; + + var attendance = await _attendanceRepository.FirstOrDefaultAsync( + x => x.SessionId == sessionId && x.StudentId == userId.Value && x.LeaveTime == null + ); + + if (attendance != null) { - var attendance = await _attendanceRepository.FirstOrDefaultAsync( - x => x.SessionId == sessionId && x.StudentId == userId.Value && x.LeaveTime == null - ); + await CloseAttendanceAsync(attendance); + await Clients.Group(sessionId.ToString()).SendAsync("AttendanceUpdated", attendance); + } - if (attendance != null) - { - attendance.LeaveTime = DateTime.UtcNow; - attendance.TotalDurationMinutes = (int)Math.Max( - 1, - (attendance.LeaveTime.Value - attendance.JoinTime).TotalMinutes - ); - await _attendanceRepository.UpdateAsync(attendance, autoSave: true); + var participant = await _participantRepository.FirstOrDefaultAsync( + x => x.SessionId == sessionId && x.UserId == userId + ); - await Clients.Group(sessionId.ToString()) - .SendAsync("AttendanceUpdated", attendance); - } - - //Kullanıcıyı Pasife aldım. - var participant = await _participantRepository.FirstOrDefaultAsync( - x => x.SessionId == sessionId && x.UserId == userId - ); - - if (participant != null) - { - participant.IsActive = false; - await _participantRepository.UpdateAsync(participant, autoSave: true); - } + if (participant != null) + { + await DeactivateParticipantAsync(participant); } await Clients.Group(sessionId.ToString()) - .SendAsync("ParticipantLeft", _currentUser.Id.ToString()); + .SendAsync("ParticipantLeft", userId.Value); - _logger.LogInformation($"User {_currentUser} left class {sessionId}"); + _logger.LogInformation("User {UserId} left class {SessionId}", userId, sessionId); } [HubMethodName("MuteParticipant")] @@ -184,10 +229,7 @@ public class ClassroomHub : Hub if (participant != null) { - if (isMuted) participant.MuteAudio(); - else participant.UnmuteAudio(); - - await _participantRepository.UpdateAsync(participant, autoSave: true); + await SetMuteStateAsync(participant, isMuted); await Clients.Group(sessionId.ToString()) .SendAsync("ParticipantMuted", userId, isMuted); @@ -195,15 +237,8 @@ public class ClassroomHub : Hub } [HubMethodName("SendChatMessage")] - public async Task SendChatMessageAsync( - Guid sessionId, - Guid senderId, - string senderName, - string message, - bool isTeacher, - string messageType) + public async Task SendChatMessageAsync(Guid sessionId, Guid senderId, string senderName, string message, bool isTeacher, string messageType) { - // Save message to DB var chatMessage = new ClassroomChat( _guidGenerator.Create(), sessionId, @@ -216,19 +251,7 @@ public class ClassroomHub : Hub messageType ); - await _chatMessageRepository.InsertAsync(chatMessage, autoSave: true); - - // Broadcast to group - await Clients.Group(sessionId.ToString()).SendAsync("ChatMessage", new - { - Id = chatMessage.Id, - SenderId = senderId, - SenderName = senderName, - Message = chatMessage.Message, - Timestamp = chatMessage.Timestamp, - IsTeacher = isTeacher, - MessageType = messageType - }); + await BroadcastChatMessageAsync(chatMessage, sessionId); } [HubMethodName("SendPrivateMessage")] @@ -242,7 +265,6 @@ public class ClassroomHub : Hub bool isTeacher, string messageType) { - // Save message to DB var chatMessage = new ClassroomChat( _guidGenerator.Create(), sessionId, @@ -287,7 +309,6 @@ public class ClassroomHub : Hub [HubMethodName("SendAnnouncement")] public async Task SendAnnouncementAsync(Guid sessionId, Guid senderId, string senderName, string message, bool isTeacher) { - // Save message to DB var chatMessage = new ClassroomChat( _guidGenerator.Create(), sessionId, @@ -300,18 +321,7 @@ public class ClassroomHub : Hub "announcement" ); - await _chatMessageRepository.InsertAsync(chatMessage, autoSave: true); - - await Clients.Group(sessionId.ToString()).SendAsync("ChatMessage", new - { - Id = Guid.NewGuid(), - SenderId = senderId, - SenderName = senderName, - Message = message, - Timestamp = DateTime.UtcNow, - IsTeacher = isTeacher, - MessageType = "announcement" - }); + await BroadcastChatMessageAsync(chatMessage, sessionId); } [HubMethodName("RaiseHand")] @@ -341,47 +351,27 @@ public class ClassroomHub : Hub [HubMethodName("KickParticipant")] public async Task KickParticipantAsync(Guid sessionId, Guid participantId) { - // Attendance kapat var attendance = await _attendanceRepository.FirstOrDefaultAsync( x => x.SessionId == sessionId && x.StudentId == participantId && x.LeaveTime == null ); if (attendance != null) { - attendance.LeaveTime = DateTime.UtcNow; - attendance.TotalDurationMinutes = (int)Math.Max( - 1, - (attendance.LeaveTime.Value - attendance.JoinTime).TotalMinutes - ); - - await _attendanceRepository.UpdateAsync(attendance, autoSave: true); - - // Katılım güncellemesini yayınla - await Clients.Group(sessionId.ToString()).SendAsync("AttendanceUpdated", new - { - attendance.Id, - attendance.SessionId, - attendance.StudentId, - attendance.StudentName, - attendance.JoinTime, - attendance.LeaveTime, - attendance.TotalDurationMinutes - }); + await CloseAttendanceAsync(attendance); + await Clients.Group(sessionId.ToString()).SendAsync("AttendanceUpdated", attendance); } - // 🔑 Participant’i pasife al var participant = await _participantRepository.FirstOrDefaultAsync( x => x.SessionId == sessionId && x.UserId == participantId ); + if (participant != null) { - participant.IsActive = false; - await _participantRepository.UpdateAsync(participant, autoSave: true); + await DeactivateParticipantAsync(participant); } _logger.LogInformation("👢 Participant {ParticipantId} kicked from session {SessionId}", participantId, sessionId); - // Katılımcı çıkışını bildir await Clients.Group(sessionId.ToString()).SendAsync("ParticipantLeft", participantId); } @@ -489,6 +479,7 @@ public class ClassroomHub : Hub } participant.IsActive = false; + participant.ConnectionId = null; await _participantRepository.UpdateAsync(participant, autoSave: true); // 🔑 3. ParticipantLeft event’i @@ -510,6 +501,8 @@ public class ClassroomHub : Hub } } + + public class SignalingMessageDto { public string Type { get; set; } // offer, answer, ice-candidate diff --git a/ui/src/components/classroom/ParticipantGrid.tsx b/ui/src/components/classroom/ParticipantGrid.tsx index 501156cd..1f52115c 100644 --- a/ui/src/components/classroom/ParticipantGrid.tsx +++ b/ui/src/components/classroom/ParticipantGrid.tsx @@ -5,7 +5,7 @@ import { ClassroomParticipantDto, VideoLayoutDto } from '@/proxy/classroom/model interface ParticipantGridProps { participants: ClassroomParticipantDto[] - localStream?: MediaStream + localStream?: MediaStream | null currentUserId: string currentUserName: string isTeacher: boolean @@ -45,8 +45,8 @@ export const ParticipantGrid: React.FC = ({ id: currentUserId, name: currentUserName, isTeacher, - stream: localStream, - } + stream: localStream ?? undefined, // null yerine undefined + } as unknown as ClassroomParticipantDto // Eğer hiç katılımcı yoksa ve localStream de yoksa hiçbir şey render etme if (!localStream && (!participants || participants.length === 0)) { diff --git a/ui/src/components/classroom/VideoPlayer.tsx b/ui/src/components/classroom/VideoPlayer.tsx index 793bb45e..ad5e6c98 100644 --- a/ui/src/components/classroom/VideoPlayer.tsx +++ b/ui/src/components/classroom/VideoPlayer.tsx @@ -23,9 +23,6 @@ export const VideoPlayer: React.FC = ({ userName, isAudioEnabled = true, isVideoEnabled = true, - onToggleAudio, - onToggleVideo, - onLeaveCall, }) => { const videoRef = useRef(null) @@ -36,24 +33,26 @@ export const VideoPlayer: React.FC = ({ if (stream) { videoEl.srcObject = stream } else { - videoEl.srcObject = null // 🟢 ayrıldığında video siyaha düşer + videoEl.srcObject = null } return () => { if (videoEl) { - videoEl.srcObject = null // 🟢 cleanup + videoEl.srcObject = null } } }, [stream]) return (
+ {/* Video sadece kamera açıkken göster */}
- {/* Video disabled overlay */} + {/* Video kapalıysa avatar/placeholder göster */} {!isVideoEnabled && (
diff --git a/ui/src/components/classroom/panels/ChatPanel.tsx b/ui/src/components/classroom/panels/ChatPanel.tsx index c6bba61d..dd74e161 100644 --- a/ui/src/components/classroom/panels/ChatPanel.tsx +++ b/ui/src/components/classroom/panels/ChatPanel.tsx @@ -1,6 +1,6 @@ import React, { useRef, useEffect } from 'react' import { FaTimes, FaUsers, FaUser, FaBullhorn, FaPaperPlane } from 'react-icons/fa' -import { ClassroomChatDto, ClassroomParticipantDto } from '@/proxy/classroom/models' +import { ClassroomChatDto, ClassroomParticipantDto, MessageType } from '@/proxy/classroom/models' interface ChatPanelProps { user: { id: string; name: string; role: string } @@ -8,8 +8,8 @@ interface ChatPanelProps { chatMessages: ClassroomChatDto[] newMessage: string setNewMessage: (msg: string) => void - messageMode: 'public' | 'private' | 'announcement' - setMessageMode: (mode: 'public' | 'private' | 'announcement') => void + messageMode: MessageType + setMessageMode: (mode: MessageType) => void selectedRecipient: { id: string; name: string } | null setSelectedRecipient: (recipient: { id: string; name: string } | null) => void onSendMessage: (e: React.FormEvent) => void diff --git a/ui/src/proxy/classroom/models.ts b/ui/src/proxy/classroom/models.ts index 5d2cadb3..9f4820d4 100644 --- a/ui/src/proxy/classroom/models.ts +++ b/ui/src/proxy/classroom/models.ts @@ -4,6 +4,10 @@ export type RoleState = 'role-selection' | 'dashboard' | 'classroom' export type Role = 'teacher' | 'student' | 'observer' +export type MessageType = 'public' | 'private' | 'announcement' + +export type VideoLayoutType = 'grid' | 'sidebar' | 'teacher-focus' + export interface User { id: string name: string @@ -64,7 +68,6 @@ export interface ClassroomParticipantDto { peerConnection?: RTCPeerConnection } -export type messageType = 'public' | 'private' | 'announcement' export interface ClassroomChatDto { id: string @@ -76,11 +79,9 @@ export interface ClassroomChatDto { isTeacher: boolean recipientId?: string recipientName?: string - messageType: messageType + messageType: MessageType } -export type VideoLayoutType = 'grid' | 'sidebar' | 'teacher-focus' - export interface VideoLayoutDto { id: string name: string diff --git a/ui/src/services/classroom/signalr.ts b/ui/src/services/classroom/signalr.ts index 403d4b56..8def144d 100644 --- a/ui/src/services/classroom/signalr.ts +++ b/ui/src/services/classroom/signalr.ts @@ -1,4 +1,4 @@ -import { ClassroomAttendanceDto, ClassroomChatDto, HandRaiseDto } from '@/proxy/classroom/models' +import { ClassroomAttendanceDto, ClassroomChatDto, HandRaiseDto, MessageType } from '@/proxy/classroom/models' import { store } from '@/store/store' import * as signalR from '@microsoft/signalr' @@ -6,7 +6,12 @@ export class SignalRService { private connection!: signalR.HubConnection private isConnected: boolean = false private onAttendanceUpdate?: (record: ClassroomAttendanceDto) => void - private onParticipantJoined?: (userId: string, name: string, isTeacher: boolean, isActive: boolean) => void + private onParticipantJoined?: ( + userId: string, + name: string, + isTeacher: boolean, + isActive: boolean, + ) => void private onParticipantLeft?: (userId: string) => void private onChatMessage?: (message: ClassroomChatDto) => void private onParticipantMuted?: (userId: string, isMuted: boolean) => void @@ -39,9 +44,12 @@ export class SignalRService { this.onAttendanceUpdate?.(record) }) - this.connection.on('ParticipantJoined', (userId: string, name: string, isTeacher: boolean, isActive: boolean) => { - this.onParticipantJoined?.(userId, name, isTeacher, isActive) - }) + this.connection.on( + 'ParticipantJoined', + (userId: string, name: string, isTeacher: boolean, isActive: boolean) => { + this.onParticipantJoined?.(userId, name, isTeacher, isActive) + }, + ) this.connection.on('ParticipantLeft', (userId: string) => { this.onParticipantLeft?.(userId) @@ -81,10 +89,12 @@ export class SignalRService { ) this.connection.onreconnected(() => { + this.isConnected = true console.log('SignalR reconnected') }) this.connection.onclose(() => { + this.isConnected = false console.log('SignalR connection closed') }) @@ -110,14 +120,23 @@ export class SignalRService { userId: string, userName: string, isTeacher: boolean, - isActive: boolean + isActive: boolean, ): Promise { if (!this.isConnected) { console.log('Error starting SignalR connection join class for', userName) return } - console.log('Joining class session:', sessionId, 'as', userName, 'isTeacher:', isTeacher, 'isActive:', isActive) + console.log( + 'Joining class session:', + sessionId, + 'as', + userName, + 'isTeacher:', + isTeacher, + 'isActive:', + isActive, + ) try { await this.connection.invoke('JoinClass', sessionId, userId, userName, isTeacher, isActive) @@ -192,7 +211,7 @@ export class SignalRService { message: string, recipientId: string, recipientName: string, - isTeacher: boolean, + isTeacher: boolean ): Promise { if (!this.isConnected) { console.log( @@ -392,7 +411,9 @@ export class SignalRService { this.onAttendanceUpdate = callback } - setParticipantJoinHandler(callback: (userId: string, name: string, isTeacher: boolean, isActive: boolean) => void) { + setParticipantJoinHandler( + callback: (userId: string, name: string, isTeacher: boolean, isActive: boolean) => void, + ) { this.onParticipantJoined = callback } diff --git a/ui/src/services/classroom/webrtc.ts b/ui/src/services/classroom/webrtc.ts index 23d6267f..cf2862ef 100644 --- a/ui/src/services/classroom/webrtc.ts +++ b/ui/src/services/classroom/webrtc.ts @@ -4,7 +4,6 @@ export class WebRTCService { private onRemoteStream?: (userId: string, stream: MediaStream) => void private onIceCandidate?: (userId: string, candidate: RTCIceCandidateInit) => void - // STUN servers for NAT traversal private rtcConfiguration: RTCConfiguration = { iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, @@ -12,20 +11,34 @@ export class WebRTCService { ], } - async initializeLocalStream(): Promise { + /** + * Local stream'i başlatır. Kamera/mikrofon ayarlarını parametreden alır. + */ + async initializeLocalStream(enableAudio: boolean, enableVideo: boolean): Promise { try { + // Eğer kamera ve mikrofon kapalıysa, getUserMedia çağrısı yapmaya gerek yok + if (!enableAudio && !enableVideo) { + this.localStream = new MediaStream() + return this.localStream + } + this.localStream = await navigator.mediaDevices.getUserMedia({ - video: { - width: { ideal: 1280 }, - height: { ideal: 720 }, - frameRate: { ideal: 30 }, - }, - audio: { - echoCancellation: true, - noiseSuppression: true, - autoGainControl: true, - }, + video: enableVideo + ? { + width: { ideal: 1280 }, + height: { ideal: 720 }, + frameRate: { ideal: 30 }, + } + : false, + audio: enableAudio + ? { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + } + : false, }) + return this.localStream } catch (error) { console.error('Error accessing media devices:', error) @@ -37,33 +50,29 @@ export class WebRTCService { const peerConnection = new RTCPeerConnection(this.rtcConfiguration) this.peerConnections.set(userId, peerConnection) - // Add local stream tracks to peer connection + // Eğer local stream varsa track'leri ekle if (this.localStream) { this.localStream.getTracks().forEach((track) => { peerConnection.addTrack(track, this.localStream!) }) } - // Handle remote stream peerConnection.ontrack = (event) => { const [remoteStream] = event.streams - console.log('Remote stream received from user:', userId) this.onRemoteStream?.(userId, remoteStream) } - // Handle ICE candidates peerConnection.onicecandidate = (event) => { if (event.candidate) { - console.log('ICE candidate generated for user:', userId, event.candidate) - // In a real implementation, this would be sent via SignalR this.onIceCandidate?.(userId, event.candidate) } } peerConnection.onconnectionstatechange = () => { - console.log(`Connection state for ${userId}:`, peerConnection.connectionState) - if (peerConnection.connectionState === 'connected') { - console.log(`Successfully connected to ${userId}`) + const state = peerConnection.connectionState + console.log(`Bağlantı durumu [${userId}]: ${state}`) + if (['failed', 'closed'].includes(state)) { + this.closePeerConnection(userId) } } @@ -75,25 +84,35 @@ export class WebRTCService { } async createOffer(userId: string): Promise { - const peerConnection = this.peerConnections.get(userId) - if (!peerConnection) throw new Error('Peer connection not found') + const pc = this.peerConnections.get(userId) + if (!pc) throw new Error('Peer connection not found') - const offer = await peerConnection.createOffer() - await peerConnection.setLocalDescription(offer) - return offer + try { + const offer = await pc.createOffer() + await pc.setLocalDescription(offer) + return offer + } catch (err) { + console.error('Offer oluşturulurken hata:', err) + throw err + } } async createAnswer( userId: string, offer: RTCSessionDescriptionInit, ): Promise { - const peerConnection = this.peerConnections.get(userId) - if (!peerConnection) throw new Error('Peer connection not found') + const pc = this.peerConnections.get(userId) + if (!pc) throw new Error('Peer connection not found') - await peerConnection.setRemoteDescription(offer) - const answer = await peerConnection.createAnswer() - await peerConnection.setLocalDescription(answer) - return answer + try { + await pc.setRemoteDescription(offer) + const answer = await pc.createAnswer() + await pc.setLocalDescription(answer) + return answer + } catch (err) { + console.error('Answer oluşturulurken hata:', err) + throw err + } } async handleAnswer(userId: string, answer: RTCSessionDescriptionInit): Promise { @@ -104,30 +123,74 @@ export class WebRTCService { } async addIceCandidate(userId: string, candidate: RTCIceCandidateInit): Promise { - const peerConnection = this.peerConnections.get(userId) - if (!peerConnection) throw new Error('Peer connection not found') + const pc = this.peerConnections.get(userId) + if (!pc) throw new Error('Peer connection not found') - await peerConnection.addIceCandidate(candidate) + if (pc.signalingState === 'stable' || pc.signalingState === 'have-remote-offer') { + try { + await pc.addIceCandidate(candidate) + } catch (err) { + console.warn(`ICE candidate eklenemedi [${userId}]:`, err) + } + } else { + console.warn(`ICE candidate atlandı [${userId}], signalingState=${pc.signalingState}`) + } } onRemoteStreamReceived(callback: (userId: string, stream: MediaStream) => void) { this.onRemoteStream = callback } - toggleVideo(enabled: boolean): void { - if (this.localStream) { - const videoTrack = this.localStream.getVideoTracks()[0] - if (videoTrack) { - videoTrack.enabled = enabled + async toggleVideo(enabled: boolean): Promise { + if (!this.localStream) return + let videoTrack = this.localStream.getVideoTracks()[0] + + if (videoTrack) { + videoTrack.enabled = enabled + } else if (enabled) { + try { + const stream = await navigator.mediaDevices.getUserMedia({ video: true }) + const newTrack = stream.getVideoTracks()[0] + if (newTrack) { + this.localStream!.addTrack(newTrack) + this.peerConnections.forEach((pc) => { + const sender = pc.getSenders().find((s) => s.track?.kind === newTrack.kind) + if (sender) { + sender.replaceTrack(newTrack) + } else { + pc.addTrack(newTrack, this.localStream!) + } + }) + } + } catch (err) { + console.error('Video açılırken hata:', err) } } } - toggleAudio(enabled: boolean): void { - if (this.localStream) { - const audioTrack = this.localStream.getAudioTracks()[0] - if (audioTrack) { - audioTrack.enabled = enabled + async toggleAudio(enabled: boolean): Promise { + if (!this.localStream) return + let audioTrack = this.localStream.getAudioTracks()[0] + + if (audioTrack) { + audioTrack.enabled = enabled + } else if (enabled) { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + const newTrack = stream.getAudioTracks()[0] + if (newTrack) { + this.localStream!.addTrack(newTrack) + this.peerConnections.forEach((pc) => { + const sender = pc.getSenders().find((s) => s.track?.kind === newTrack.kind) + if (sender) { + sender.replaceTrack(newTrack) + } else { + pc.addTrack(newTrack, this.localStream!) + } + }) + } + } catch (err) { + console.error('Audio açılırken hata:', err) } } } @@ -139,6 +202,7 @@ export class WebRTCService { closePeerConnection(userId: string): void { const peerConnection = this.peerConnections.get(userId) if (peerConnection) { + peerConnection.getSenders().forEach((sender) => sender.track?.stop()) peerConnection.close() this.peerConnections.delete(userId) } @@ -149,7 +213,10 @@ export class WebRTCService { } closeAllConnections(): void { - this.peerConnections.forEach((pc) => pc.close()) + this.peerConnections.forEach((pc) => { + pc.getSenders().forEach((sender) => sender.track?.stop()) + pc.close() + }) this.peerConnections.clear() if (this.localStream) { @@ -157,4 +224,36 @@ export class WebRTCService { this.localStream = null } } + + addStreamToPeers(stream: MediaStream) { + this.peerConnections.forEach((pc) => { + stream.getTracks().forEach((track) => { + const alreadyHas = pc.getSenders().some((s) => s.track?.id === track.id) + if (!alreadyHas) { + pc.addTrack(track, stream) + // 🔑 track bittiğinde otomatik sil + track.onended = () => { + this.removeTrackFromPeers(track) + } + } + }) + }) + } + + removeTrackFromPeers(track: MediaStreamTrack) { + this.peerConnections.forEach((pc) => { + pc.getSenders().forEach((sender) => { + if (sender.track === track) { + try { + pc.removeTrack(sender) + } catch (err) { + console.warn('removeTrack hata verdi:', err) + } + if (sender.track?.readyState !== 'ended') { + sender.track?.stop() + } + } + }) + }) + } } diff --git a/ui/src/views/classroom/RoomDetail.tsx b/ui/src/views/classroom/RoomDetail.tsx index 568a3aa9..111824fc 100644 --- a/ui/src/views/classroom/RoomDetail.tsx +++ b/ui/src/views/classroom/RoomDetail.tsx @@ -4,7 +4,6 @@ import { FaUsers, FaComments, FaUserPlus, - FaTh, FaExpand, FaHandPaper, FaVolumeMute, @@ -37,6 +36,7 @@ import { ClassroomDto, ClassroomSettingsDto, VideoLayoutDto, + MessageType, } from '@/proxy/classroom/models' import { useStoreState } from '@/store/store' import { KickParticipantModal } from '@/components/classroom/KickParticipantModal' @@ -91,7 +91,7 @@ const RoomDetail: React.FC = () => { const [classSession, setClassSession] = useState(newClassSession) const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [participants, setParticipants] = useState([]) - const [localStream, setLocalStream] = useState() + const [localStream, setLocalStream] = useState(null) const [isAudioEnabled, setIsAudioEnabled] = useState(true) const [isVideoEnabled, setIsVideoEnabled] = useState(true) const [attendanceRecords, setAttendanceRecords] = useState([]) @@ -115,7 +115,7 @@ const RoomDetail: React.FC = () => { const [isFullscreen, setIsFullscreen] = useState(false) const [activeSidePanel, setActiveSidePanel] = useState(null) const [newMessage, setNewMessage] = useState('') - const [messageMode, setMessageMode] = useState<'public' | 'private' | 'announcement'>('public') + const [messageMode, setMessageMode] = useState('public') const [selectedRecipient, setSelectedRecipient] = useState<{ id: string; name: string } | null>( null, ) @@ -226,10 +226,17 @@ const RoomDetail: React.FC = () => { signalRServiceRef.current = new SignalRService() await signalRServiceRef.current.start() - // Initialize WebRTC + const micEnabled = classSession.settingsDto?.defaultMicrophoneState === 'unmuted' + const camEnabled = classSession.settingsDto?.defaultCameraState === 'on' + + // WebRTC başlat webRTCServiceRef.current = new WebRTCService() - const stream = await webRTCServiceRef.current.initializeLocalStream() - setLocalStream(stream) + 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) => { @@ -290,8 +297,8 @@ const RoomDetail: React.FC = () => { await webRTCServiceRef.current?.createPeerConnection(userId) } - // sadece aktif katılımcılara offer başlat - if (isActive && user.id < userId) { + // ✅ öğretmen ise her zaman offer başlatır + if (isTeacher || (isActive && user.id < userId)) { const offer = await webRTCServiceRef.current!.createOffer(userId) await signalRServiceRef.current?.sendOffer(classSession.id, userId, offer) } @@ -337,7 +344,10 @@ const RoomDetail: React.FC = () => { if (!webRTCServiceRef.current?.getPeerConnection(participant.userId)) { await webRTCServiceRef.current?.createPeerConnection(participant.userId) } - if (user.id < participant.userId) { + if ( + participant.isTeacher || + (participant.isActive && user.id < participant.userId) + ) { const offer = await webRTCServiceRef.current!.createOffer(participant.userId) await signalRServiceRef.current?.sendOffer( classSession.id, @@ -535,8 +545,8 @@ const RoomDetail: React.FC = () => { const handleKickParticipant = async (participantId: string) => { if (signalRServiceRef.current && user.role === 'teacher') { await signalRServiceRef.current.kickParticipant(classSession.id, participantId) - setParticipants((prev) => prev.filter((p) => p.id !== participantId)) - // Update attendance record for kicked participant + // ❌ state’den manuel silme yok + // attendance update kısmı aynı kalabilir setAttendanceRecords((prev) => prev.map((r) => { if (r.studentId === participantId && !r.leaveTime) { @@ -580,17 +590,31 @@ const RoomDetail: React.FC = () => { const handleStartScreenShare = async () => { try { - const stream = await navigator.mediaDevices.getDisplayMedia({ + // 1. sadece ekran videosu al + const screen = await navigator.mediaDevices.getDisplayMedia({ video: true, - audio: true, }) - setScreenStream(stream) + // 2. mikrofonu ayrı al + let mic: MediaStream | null = null + try { + mic = await navigator.mediaDevices.getUserMedia({ audio: true }) + } catch (err) { + console.warn('Mic alınamadı, sadece ekran paylaşılacak', err) + } + + // 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 - stream.getVideoTracks()[0].onended = () => { + screen.getVideoTracks()[0].onended = () => { handleStopScreenShare() } } catch (error) { @@ -600,7 +624,11 @@ const RoomDetail: React.FC = () => { const handleStopScreenShare = () => { if (screenStream) { - screenStream.getTracks().forEach((track) => track.stop()) + // PeerConnections’tan kaldır + screenStream.getTracks().forEach((track) => { + webRTCServiceRef.current?.removeTrackFromPeers(track) + track.stop() + }) setScreenStream(undefined) } setIsScreenSharing(false)