Classroom SignalR ve WebRtc güvenlik düzenlemesi

This commit is contained in:
Sedat Öztürk 2025-08-30 22:57:47 +03:00
parent 7c882cb5d8
commit a09f65f53d
8 changed files with 343 additions and 202 deletions

View file

@ -41,6 +41,79 @@ public class ClassroomHub : Hub
_currentUser = currentUser; _currentUser = currentUser;
} }
#region Helper Methods
private async Task<ClassroomAttandance> 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")] [HubMethodName("JoinClass")]
public async Task JoinClassAsync(Guid sessionId, Guid userId, string userName, bool isTeacher, bool isActive) 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) var classroomSettings = string.IsNullOrWhiteSpace(classroom.SettingsJson)
? new ClassroomSettingsDto() // default ayarlar ? new ClassroomSettingsDto()
: JsonSerializer.Deserialize<ClassroomSettingsDto>(classroom.SettingsJson); : JsonSerializer.Deserialize<ClassroomSettingsDto>(classroom.SettingsJson);
var participant = await _participantRepository.FirstOrDefaultAsync( var participant = await _participantRepository.FirstOrDefaultAsync(
@ -72,34 +145,19 @@ public class ClassroomHub : Hub
false, false,
isActive isActive
); );
participant.UpdateConnectionId(Context.ConnectionId);
await _participantRepository.InsertAsync(participant, autoSave: true); await _participantRepository.InsertAsync(participant, autoSave: true);
await UpdateParticipantConnectionAsync(participant, Context.ConnectionId, isActive);
// 🔑 Katılımcı sayısını güncelle await UpdateParticipantCountAsync(sessionId, classroom);
var participantCount = await _participantRepository.CountAsync(x => x.SessionId == sessionId);
classroom.ParticipantCount = participantCount;
await _classSessionRepository.UpdateAsync(classroom, autoSave: true);
} }
else else
{ {
participant.UpdateConnectionId(Context.ConnectionId); await UpdateParticipantConnectionAsync(participant, Context.ConnectionId, isActive);
participant.IsActive = isActive; // Aktiflik durumunu güncelle
await _participantRepository.UpdateAsync(participant, autoSave: true);
} }
// 🔑 Attendance kaydı await CreateAttendanceAsync(sessionId, userId, userName);
var attendance = new ClassroomAttandance(
_guidGenerator.Create(),
sessionId,
userId,
userName,
DateTime.UtcNow
);
await _attendanceRepository.InsertAsync(attendance, autoSave: true);
await Groups.AddToGroupAsync(Context.ConnectionId, sessionId.ToString()); await Groups.AddToGroupAsync(Context.ConnectionId, sessionId.ToString());
// 🔑 Yeni katılana mevcut aktif katılımcıları gönder
var existingParticipants = await _participantRepository.GetListAsync( var existingParticipants = await _participantRepository.GetListAsync(
x => x.SessionId == sessionId && x.IsActive x => x.SessionId == sessionId && x.IsActive
); );
@ -111,58 +169,45 @@ public class ClassroomHub : Hub
UserId = x.UserId, UserId = x.UserId,
UserName = x.UserName, UserName = x.UserName,
IsTeacher = x.IsTeacher, IsTeacher = x.IsTeacher,
IsActive = x.IsActive // ✅ aktiflik bilgisini de gönder IsActive = x.IsActive
}) })
.ToList(); .ToList();
await Clients.Caller.SendAsync("ExistingParticipants", others); await Clients.Caller.SendAsync("ExistingParticipants", others);
// 🔑 Grup üyelerine yeni katılanı öğretmen bilgisiyle bildir
await Clients.Group(sessionId.ToString()) await Clients.Group(sessionId.ToString())
.SendAsync("ParticipantJoined", userId, userName, isTeacher, isActive); .SendAsync("ParticipantJoined", userId, userName, isTeacher, isActive);
} }
[HubMethodName("LeaveClass")] [HubMethodName("LeaveClass")]
public async Task LeaveClassAsync(Guid sessionId) public async Task LeaveClassAsync(Guid sessionId)
{ {
await Groups.RemoveFromGroupAsync(Context.ConnectionId, sessionId.ToString()); await Groups.RemoveFromGroupAsync(Context.ConnectionId, sessionId.ToString());
var userId = _currentUser.Id; 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( await CloseAttendanceAsync(attendance);
x => x.SessionId == sessionId && x.StudentId == userId.Value && x.LeaveTime == null await Clients.Group(sessionId.ToString()).SendAsync("AttendanceUpdated", attendance);
); }
if (attendance != null) var participant = await _participantRepository.FirstOrDefaultAsync(
{ x => x.SessionId == sessionId && x.UserId == userId
attendance.LeaveTime = DateTime.UtcNow; );
attendance.TotalDurationMinutes = (int)Math.Max(
1,
(attendance.LeaveTime.Value - attendance.JoinTime).TotalMinutes
);
await _attendanceRepository.UpdateAsync(attendance, autoSave: true);
await Clients.Group(sessionId.ToString()) if (participant != null)
.SendAsync("AttendanceUpdated", attendance); {
} await DeactivateParticipantAsync(participant);
//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);
}
} }
await Clients.Group(sessionId.ToString()) 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")] [HubMethodName("MuteParticipant")]
@ -184,10 +229,7 @@ public class ClassroomHub : Hub
if (participant != null) if (participant != null)
{ {
if (isMuted) participant.MuteAudio(); await SetMuteStateAsync(participant, isMuted);
else participant.UnmuteAudio();
await _participantRepository.UpdateAsync(participant, autoSave: true);
await Clients.Group(sessionId.ToString()) await Clients.Group(sessionId.ToString())
.SendAsync("ParticipantMuted", userId, isMuted); .SendAsync("ParticipantMuted", userId, isMuted);
@ -195,15 +237,8 @@ public class ClassroomHub : Hub
} }
[HubMethodName("SendChatMessage")] [HubMethodName("SendChatMessage")]
public async Task SendChatMessageAsync( public async Task SendChatMessageAsync(Guid sessionId, Guid senderId, string senderName, string message, bool isTeacher, string messageType)
Guid sessionId,
Guid senderId,
string senderName,
string message,
bool isTeacher,
string messageType)
{ {
// Save message to DB
var chatMessage = new ClassroomChat( var chatMessage = new ClassroomChat(
_guidGenerator.Create(), _guidGenerator.Create(),
sessionId, sessionId,
@ -216,19 +251,7 @@ public class ClassroomHub : Hub
messageType messageType
); );
await _chatMessageRepository.InsertAsync(chatMessage, autoSave: true); await BroadcastChatMessageAsync(chatMessage, sessionId);
// 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
});
} }
[HubMethodName("SendPrivateMessage")] [HubMethodName("SendPrivateMessage")]
@ -242,7 +265,6 @@ public class ClassroomHub : Hub
bool isTeacher, bool isTeacher,
string messageType) string messageType)
{ {
// Save message to DB
var chatMessage = new ClassroomChat( var chatMessage = new ClassroomChat(
_guidGenerator.Create(), _guidGenerator.Create(),
sessionId, sessionId,
@ -287,7 +309,6 @@ public class ClassroomHub : Hub
[HubMethodName("SendAnnouncement")] [HubMethodName("SendAnnouncement")]
public async Task SendAnnouncementAsync(Guid sessionId, Guid senderId, string senderName, string message, bool isTeacher) public async Task SendAnnouncementAsync(Guid sessionId, Guid senderId, string senderName, string message, bool isTeacher)
{ {
// Save message to DB
var chatMessage = new ClassroomChat( var chatMessage = new ClassroomChat(
_guidGenerator.Create(), _guidGenerator.Create(),
sessionId, sessionId,
@ -300,18 +321,7 @@ public class ClassroomHub : Hub
"announcement" "announcement"
); );
await _chatMessageRepository.InsertAsync(chatMessage, autoSave: true); await BroadcastChatMessageAsync(chatMessage, sessionId);
await Clients.Group(sessionId.ToString()).SendAsync("ChatMessage", new
{
Id = Guid.NewGuid(),
SenderId = senderId,
SenderName = senderName,
Message = message,
Timestamp = DateTime.UtcNow,
IsTeacher = isTeacher,
MessageType = "announcement"
});
} }
[HubMethodName("RaiseHand")] [HubMethodName("RaiseHand")]
@ -341,47 +351,27 @@ public class ClassroomHub : Hub
[HubMethodName("KickParticipant")] [HubMethodName("KickParticipant")]
public async Task KickParticipantAsync(Guid sessionId, Guid participantId) public async Task KickParticipantAsync(Guid sessionId, Guid participantId)
{ {
// Attendance kapat
var attendance = await _attendanceRepository.FirstOrDefaultAsync( var attendance = await _attendanceRepository.FirstOrDefaultAsync(
x => x.SessionId == sessionId && x.StudentId == participantId && x.LeaveTime == null x => x.SessionId == sessionId && x.StudentId == participantId && x.LeaveTime == null
); );
if (attendance != null) if (attendance != null)
{ {
attendance.LeaveTime = DateTime.UtcNow; await CloseAttendanceAsync(attendance);
attendance.TotalDurationMinutes = (int)Math.Max( await Clients.Group(sessionId.ToString()).SendAsync("AttendanceUpdated", attendance);
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
});
} }
// 🔑 Participanti pasife al
var participant = await _participantRepository.FirstOrDefaultAsync( var participant = await _participantRepository.FirstOrDefaultAsync(
x => x.SessionId == sessionId && x.UserId == participantId x => x.SessionId == sessionId && x.UserId == participantId
); );
if (participant != null) if (participant != null)
{ {
participant.IsActive = false; await DeactivateParticipantAsync(participant);
await _participantRepository.UpdateAsync(participant, autoSave: true);
} }
_logger.LogInformation("👢 Participant {ParticipantId} kicked from session {SessionId}", participantId, sessionId); _logger.LogInformation("👢 Participant {ParticipantId} kicked from session {SessionId}", participantId, sessionId);
// Katılımcı çıkışını bildir
await Clients.Group(sessionId.ToString()).SendAsync("ParticipantLeft", participantId); await Clients.Group(sessionId.ToString()).SendAsync("ParticipantLeft", participantId);
} }
@ -489,6 +479,7 @@ public class ClassroomHub : Hub
} }
participant.IsActive = false; participant.IsActive = false;
participant.ConnectionId = null;
await _participantRepository.UpdateAsync(participant, autoSave: true); await _participantRepository.UpdateAsync(participant, autoSave: true);
// 🔑 3. ParticipantLeft eventi // 🔑 3. ParticipantLeft eventi
@ -510,6 +501,8 @@ public class ClassroomHub : Hub
} }
} }
public class SignalingMessageDto public class SignalingMessageDto
{ {
public string Type { get; set; } // offer, answer, ice-candidate public string Type { get; set; } // offer, answer, ice-candidate

View file

@ -5,7 +5,7 @@ import { ClassroomParticipantDto, VideoLayoutDto } from '@/proxy/classroom/model
interface ParticipantGridProps { interface ParticipantGridProps {
participants: ClassroomParticipantDto[] participants: ClassroomParticipantDto[]
localStream?: MediaStream localStream?: MediaStream | null
currentUserId: string currentUserId: string
currentUserName: string currentUserName: string
isTeacher: boolean isTeacher: boolean
@ -45,8 +45,8 @@ export const ParticipantGrid: React.FC<ParticipantGridProps> = ({
id: currentUserId, id: currentUserId,
name: currentUserName, name: currentUserName,
isTeacher, 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 // Eğer hiç katılımcı yoksa ve localStream de yoksa hiçbir şey render etme
if (!localStream && (!participants || participants.length === 0)) { if (!localStream && (!participants || participants.length === 0)) {

View file

@ -23,9 +23,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
userName, userName,
isAudioEnabled = true, isAudioEnabled = true,
isVideoEnabled = true, isVideoEnabled = true,
onToggleAudio,
onToggleVideo,
onLeaveCall,
}) => { }) => {
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
@ -36,24 +33,26 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
if (stream) { if (stream) {
videoEl.srcObject = stream videoEl.srcObject = stream
} else { } else {
videoEl.srcObject = null // 🟢 ayrıldığında video siyaha düşer videoEl.srcObject = null
} }
return () => { return () => {
if (videoEl) { if (videoEl) {
videoEl.srcObject = null // 🟢 cleanup videoEl.srcObject = null
} }
} }
}, [stream]) }, [stream])
return ( return (
<div className="relative bg-gray-900 rounded-md sm:rounded-lg overflow-hidden p-1 sm:p-2 h-full"> <div className="relative bg-gray-900 rounded-md sm:rounded-lg overflow-hidden p-1 sm:p-2 h-full">
{/* Video sadece kamera açıkken göster */}
<video <video
ref={videoRef} ref={videoRef}
autoPlay autoPlay
playsInline playsInline
muted={isLocal} muted={isLocal}
className="w-full h-full object-cover" className="w-full h-full object-cover"
style={{ display: isVideoEnabled ? 'block' : 'none' }}
/> />
{/* User name overlay */} {/* User name overlay */}
@ -61,7 +60,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
{userName} {isLocal && '(You)'} {userName} {isLocal && '(You)'}
</div> </div>
{/* Video disabled overlay */} {/* Video kapalıysa avatar/placeholder göster */}
{!isVideoEnabled && ( {!isVideoEnabled && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-800"> <div className="absolute inset-0 flex items-center justify-center bg-gray-800">
<div className="text-center text-white"> <div className="text-center text-white">

View file

@ -1,6 +1,6 @@
import React, { useRef, useEffect } from 'react' import React, { useRef, useEffect } from 'react'
import { FaTimes, FaUsers, FaUser, FaBullhorn, FaPaperPlane } from 'react-icons/fa' 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 { interface ChatPanelProps {
user: { id: string; name: string; role: string } user: { id: string; name: string; role: string }
@ -8,8 +8,8 @@ interface ChatPanelProps {
chatMessages: ClassroomChatDto[] chatMessages: ClassroomChatDto[]
newMessage: string newMessage: string
setNewMessage: (msg: string) => void setNewMessage: (msg: string) => void
messageMode: 'public' | 'private' | 'announcement' messageMode: MessageType
setMessageMode: (mode: 'public' | 'private' | 'announcement') => void setMessageMode: (mode: MessageType) => void
selectedRecipient: { id: string; name: string } | null selectedRecipient: { id: string; name: string } | null
setSelectedRecipient: (recipient: { id: string; name: string } | null) => void setSelectedRecipient: (recipient: { id: string; name: string } | null) => void
onSendMessage: (e: React.FormEvent) => void onSendMessage: (e: React.FormEvent) => void

View file

@ -4,6 +4,10 @@ export type RoleState = 'role-selection' | 'dashboard' | 'classroom'
export type Role = 'teacher' | 'student' | 'observer' export type Role = 'teacher' | 'student' | 'observer'
export type MessageType = 'public' | 'private' | 'announcement'
export type VideoLayoutType = 'grid' | 'sidebar' | 'teacher-focus'
export interface User { export interface User {
id: string id: string
name: string name: string
@ -64,7 +68,6 @@ export interface ClassroomParticipantDto {
peerConnection?: RTCPeerConnection peerConnection?: RTCPeerConnection
} }
export type messageType = 'public' | 'private' | 'announcement'
export interface ClassroomChatDto { export interface ClassroomChatDto {
id: string id: string
@ -76,11 +79,9 @@ export interface ClassroomChatDto {
isTeacher: boolean isTeacher: boolean
recipientId?: string recipientId?: string
recipientName?: string recipientName?: string
messageType: messageType messageType: MessageType
} }
export type VideoLayoutType = 'grid' | 'sidebar' | 'teacher-focus'
export interface VideoLayoutDto { export interface VideoLayoutDto {
id: string id: string
name: string name: string

View file

@ -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 { store } from '@/store/store'
import * as signalR from '@microsoft/signalr' import * as signalR from '@microsoft/signalr'
@ -6,7 +6,12 @@ export class SignalRService {
private connection!: signalR.HubConnection private connection!: signalR.HubConnection
private isConnected: boolean = false private isConnected: boolean = false
private onAttendanceUpdate?: (record: ClassroomAttendanceDto) => void 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 onParticipantLeft?: (userId: string) => void
private onChatMessage?: (message: ClassroomChatDto) => void private onChatMessage?: (message: ClassroomChatDto) => void
private onParticipantMuted?: (userId: string, isMuted: boolean) => void private onParticipantMuted?: (userId: string, isMuted: boolean) => void
@ -39,9 +44,12 @@ export class SignalRService {
this.onAttendanceUpdate?.(record) this.onAttendanceUpdate?.(record)
}) })
this.connection.on('ParticipantJoined', (userId: string, name: string, isTeacher: boolean, isActive: boolean) => { this.connection.on(
this.onParticipantJoined?.(userId, name, isTeacher, isActive) 'ParticipantJoined',
}) (userId: string, name: string, isTeacher: boolean, isActive: boolean) => {
this.onParticipantJoined?.(userId, name, isTeacher, isActive)
},
)
this.connection.on('ParticipantLeft', (userId: string) => { this.connection.on('ParticipantLeft', (userId: string) => {
this.onParticipantLeft?.(userId) this.onParticipantLeft?.(userId)
@ -81,10 +89,12 @@ export class SignalRService {
) )
this.connection.onreconnected(() => { this.connection.onreconnected(() => {
this.isConnected = true
console.log('SignalR reconnected') console.log('SignalR reconnected')
}) })
this.connection.onclose(() => { this.connection.onclose(() => {
this.isConnected = false
console.log('SignalR connection closed') console.log('SignalR connection closed')
}) })
@ -110,14 +120,23 @@ export class SignalRService {
userId: string, userId: string,
userName: string, userName: string,
isTeacher: boolean, isTeacher: boolean,
isActive: boolean isActive: boolean,
): Promise<void> { ): Promise<void> {
if (!this.isConnected) { if (!this.isConnected) {
console.log('Error starting SignalR connection join class for', userName) console.log('Error starting SignalR connection join class for', userName)
return 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 { try {
await this.connection.invoke('JoinClass', sessionId, userId, userName, isTeacher, isActive) await this.connection.invoke('JoinClass', sessionId, userId, userName, isTeacher, isActive)
@ -192,7 +211,7 @@ export class SignalRService {
message: string, message: string,
recipientId: string, recipientId: string,
recipientName: string, recipientName: string,
isTeacher: boolean, isTeacher: boolean
): Promise<void> { ): Promise<void> {
if (!this.isConnected) { if (!this.isConnected) {
console.log( console.log(
@ -392,7 +411,9 @@ export class SignalRService {
this.onAttendanceUpdate = callback 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 this.onParticipantJoined = callback
} }

View file

@ -4,7 +4,6 @@ export class WebRTCService {
private onRemoteStream?: (userId: string, stream: MediaStream) => void private onRemoteStream?: (userId: string, stream: MediaStream) => void
private onIceCandidate?: (userId: string, candidate: RTCIceCandidateInit) => void private onIceCandidate?: (userId: string, candidate: RTCIceCandidateInit) => void
// STUN servers for NAT traversal
private rtcConfiguration: RTCConfiguration = { private rtcConfiguration: RTCConfiguration = {
iceServers: [ iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun.l.google.com:19302' },
@ -12,20 +11,34 @@ export class WebRTCService {
], ],
} }
async initializeLocalStream(): Promise<MediaStream> { /**
* Local stream'i başlatır. Kamera/mikrofon ayarlarını parametreden alır.
*/
async initializeLocalStream(enableAudio: boolean, enableVideo: boolean): Promise<MediaStream> {
try { 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({ this.localStream = await navigator.mediaDevices.getUserMedia({
video: { video: enableVideo
width: { ideal: 1280 }, ? {
height: { ideal: 720 }, width: { ideal: 1280 },
frameRate: { ideal: 30 }, height: { ideal: 720 },
}, frameRate: { ideal: 30 },
audio: { }
echoCancellation: true, : false,
noiseSuppression: true, audio: enableAudio
autoGainControl: true, ? {
}, echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
}
: false,
}) })
return this.localStream return this.localStream
} catch (error) { } catch (error) {
console.error('Error accessing media devices:', error) console.error('Error accessing media devices:', error)
@ -37,33 +50,29 @@ export class WebRTCService {
const peerConnection = new RTCPeerConnection(this.rtcConfiguration) const peerConnection = new RTCPeerConnection(this.rtcConfiguration)
this.peerConnections.set(userId, peerConnection) this.peerConnections.set(userId, peerConnection)
// Add local stream tracks to peer connection // Eğer local stream varsa track'leri ekle
if (this.localStream) { if (this.localStream) {
this.localStream.getTracks().forEach((track) => { this.localStream.getTracks().forEach((track) => {
peerConnection.addTrack(track, this.localStream!) peerConnection.addTrack(track, this.localStream!)
}) })
} }
// Handle remote stream
peerConnection.ontrack = (event) => { peerConnection.ontrack = (event) => {
const [remoteStream] = event.streams const [remoteStream] = event.streams
console.log('Remote stream received from user:', userId)
this.onRemoteStream?.(userId, remoteStream) this.onRemoteStream?.(userId, remoteStream)
} }
// Handle ICE candidates
peerConnection.onicecandidate = (event) => { peerConnection.onicecandidate = (event) => {
if (event.candidate) { 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) this.onIceCandidate?.(userId, event.candidate)
} }
} }
peerConnection.onconnectionstatechange = () => { peerConnection.onconnectionstatechange = () => {
console.log(`Connection state for ${userId}:`, peerConnection.connectionState) const state = peerConnection.connectionState
if (peerConnection.connectionState === 'connected') { console.log(`Bağlantı durumu [${userId}]: ${state}`)
console.log(`Successfully connected to ${userId}`) if (['failed', 'closed'].includes(state)) {
this.closePeerConnection(userId)
} }
} }
@ -75,25 +84,35 @@ export class WebRTCService {
} }
async createOffer(userId: string): Promise<RTCSessionDescriptionInit> { async createOffer(userId: string): Promise<RTCSessionDescriptionInit> {
const peerConnection = this.peerConnections.get(userId) const pc = this.peerConnections.get(userId)
if (!peerConnection) throw new Error('Peer connection not found') if (!pc) throw new Error('Peer connection not found')
const offer = await peerConnection.createOffer() try {
await peerConnection.setLocalDescription(offer) const offer = await pc.createOffer()
return offer await pc.setLocalDescription(offer)
return offer
} catch (err) {
console.error('Offer oluşturulurken hata:', err)
throw err
}
} }
async createAnswer( async createAnswer(
userId: string, userId: string,
offer: RTCSessionDescriptionInit, offer: RTCSessionDescriptionInit,
): Promise<RTCSessionDescriptionInit> { ): Promise<RTCSessionDescriptionInit> {
const peerConnection = this.peerConnections.get(userId) const pc = this.peerConnections.get(userId)
if (!peerConnection) throw new Error('Peer connection not found') if (!pc) throw new Error('Peer connection not found')
await peerConnection.setRemoteDescription(offer) try {
const answer = await peerConnection.createAnswer() await pc.setRemoteDescription(offer)
await peerConnection.setLocalDescription(answer) const answer = await pc.createAnswer()
return answer await pc.setLocalDescription(answer)
return answer
} catch (err) {
console.error('Answer oluşturulurken hata:', err)
throw err
}
} }
async handleAnswer(userId: string, answer: RTCSessionDescriptionInit): Promise<void> { async handleAnswer(userId: string, answer: RTCSessionDescriptionInit): Promise<void> {
@ -104,30 +123,74 @@ export class WebRTCService {
} }
async addIceCandidate(userId: string, candidate: RTCIceCandidateInit): Promise<void> { async addIceCandidate(userId: string, candidate: RTCIceCandidateInit): Promise<void> {
const peerConnection = this.peerConnections.get(userId) const pc = this.peerConnections.get(userId)
if (!peerConnection) throw new Error('Peer connection not found') 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) { onRemoteStreamReceived(callback: (userId: string, stream: MediaStream) => void) {
this.onRemoteStream = callback this.onRemoteStream = callback
} }
toggleVideo(enabled: boolean): void { async toggleVideo(enabled: boolean): Promise<void> {
if (this.localStream) { if (!this.localStream) return
const videoTrack = this.localStream.getVideoTracks()[0] let videoTrack = this.localStream.getVideoTracks()[0]
if (videoTrack) {
videoTrack.enabled = enabled 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 { async toggleAudio(enabled: boolean): Promise<void> {
if (this.localStream) { if (!this.localStream) return
const audioTrack = this.localStream.getAudioTracks()[0] let audioTrack = this.localStream.getAudioTracks()[0]
if (audioTrack) {
audioTrack.enabled = enabled 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 { closePeerConnection(userId: string): void {
const peerConnection = this.peerConnections.get(userId) const peerConnection = this.peerConnections.get(userId)
if (peerConnection) { if (peerConnection) {
peerConnection.getSenders().forEach((sender) => sender.track?.stop())
peerConnection.close() peerConnection.close()
this.peerConnections.delete(userId) this.peerConnections.delete(userId)
} }
@ -149,7 +213,10 @@ export class WebRTCService {
} }
closeAllConnections(): void { closeAllConnections(): void {
this.peerConnections.forEach((pc) => pc.close()) this.peerConnections.forEach((pc) => {
pc.getSenders().forEach((sender) => sender.track?.stop())
pc.close()
})
this.peerConnections.clear() this.peerConnections.clear()
if (this.localStream) { if (this.localStream) {
@ -157,4 +224,36 @@ export class WebRTCService {
this.localStream = null 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()
}
}
})
})
}
} }

View file

@ -4,7 +4,6 @@ import {
FaUsers, FaUsers,
FaComments, FaComments,
FaUserPlus, FaUserPlus,
FaTh,
FaExpand, FaExpand,
FaHandPaper, FaHandPaper,
FaVolumeMute, FaVolumeMute,
@ -37,6 +36,7 @@ import {
ClassroomDto, ClassroomDto,
ClassroomSettingsDto, ClassroomSettingsDto,
VideoLayoutDto, VideoLayoutDto,
MessageType,
} from '@/proxy/classroom/models' } from '@/proxy/classroom/models'
import { useStoreState } from '@/store/store' import { useStoreState } from '@/store/store'
import { KickParticipantModal } from '@/components/classroom/KickParticipantModal' import { KickParticipantModal } from '@/components/classroom/KickParticipantModal'
@ -91,7 +91,7 @@ const RoomDetail: React.FC = () => {
const [classSession, setClassSession] = useState<ClassroomDto>(newClassSession) const [classSession, setClassSession] = useState<ClassroomDto>(newClassSession)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [participants, setParticipants] = useState<ClassroomParticipantDto[]>([]) const [participants, setParticipants] = useState<ClassroomParticipantDto[]>([])
const [localStream, setLocalStream] = useState<MediaStream>() const [localStream, setLocalStream] = useState<MediaStream | null>(null)
const [isAudioEnabled, setIsAudioEnabled] = useState(true) const [isAudioEnabled, setIsAudioEnabled] = useState(true)
const [isVideoEnabled, setIsVideoEnabled] = useState(true) const [isVideoEnabled, setIsVideoEnabled] = useState(true)
const [attendanceRecords, setAttendanceRecords] = useState<ClassroomAttendanceDto[]>([]) const [attendanceRecords, setAttendanceRecords] = useState<ClassroomAttendanceDto[]>([])
@ -115,7 +115,7 @@ const RoomDetail: React.FC = () => {
const [isFullscreen, setIsFullscreen] = useState(false) const [isFullscreen, setIsFullscreen] = useState(false)
const [activeSidePanel, setActiveSidePanel] = useState<SidePanelType>(null) const [activeSidePanel, setActiveSidePanel] = useState<SidePanelType>(null)
const [newMessage, setNewMessage] = useState('') const [newMessage, setNewMessage] = useState('')
const [messageMode, setMessageMode] = useState<'public' | 'private' | 'announcement'>('public') const [messageMode, setMessageMode] = useState<MessageType>('public')
const [selectedRecipient, setSelectedRecipient] = useState<{ id: string; name: string } | null>( const [selectedRecipient, setSelectedRecipient] = useState<{ id: string; name: string } | null>(
null, null,
) )
@ -226,10 +226,17 @@ const RoomDetail: React.FC = () => {
signalRServiceRef.current = new SignalRService() signalRServiceRef.current = new SignalRService()
await signalRServiceRef.current.start() 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() webRTCServiceRef.current = new WebRTCService()
const stream = await webRTCServiceRef.current.initializeLocalStream() const stream = await webRTCServiceRef.current.initializeLocalStream(micEnabled, camEnabled)
setLocalStream(stream) if (stream) {
setLocalStream(stream)
}
setIsAudioEnabled(micEnabled)
setIsVideoEnabled(camEnabled)
// Setup WebRTC remote stream handler // Setup WebRTC remote stream handler
webRTCServiceRef.current.onRemoteStreamReceived((userId, stream) => { webRTCServiceRef.current.onRemoteStreamReceived((userId, stream) => {
@ -290,8 +297,8 @@ const RoomDetail: React.FC = () => {
await webRTCServiceRef.current?.createPeerConnection(userId) await webRTCServiceRef.current?.createPeerConnection(userId)
} }
// sadece aktif katılımcılara offer başlat // ✅ öğretmen ise her zaman offer başlatır
if (isActive && user.id < userId) { if (isTeacher || (isActive && user.id < userId)) {
const offer = await webRTCServiceRef.current!.createOffer(userId) const offer = await webRTCServiceRef.current!.createOffer(userId)
await signalRServiceRef.current?.sendOffer(classSession.id, userId, offer) await signalRServiceRef.current?.sendOffer(classSession.id, userId, offer)
} }
@ -337,7 +344,10 @@ const RoomDetail: React.FC = () => {
if (!webRTCServiceRef.current?.getPeerConnection(participant.userId)) { if (!webRTCServiceRef.current?.getPeerConnection(participant.userId)) {
await webRTCServiceRef.current?.createPeerConnection(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) const offer = await webRTCServiceRef.current!.createOffer(participant.userId)
await signalRServiceRef.current?.sendOffer( await signalRServiceRef.current?.sendOffer(
classSession.id, classSession.id,
@ -535,8 +545,8 @@ const RoomDetail: React.FC = () => {
const handleKickParticipant = async (participantId: string) => { const handleKickParticipant = async (participantId: string) => {
if (signalRServiceRef.current && user.role === 'teacher') { if (signalRServiceRef.current && user.role === 'teacher') {
await signalRServiceRef.current.kickParticipant(classSession.id, participantId) await signalRServiceRef.current.kickParticipant(classSession.id, participantId)
setParticipants((prev) => prev.filter((p) => p.id !== participantId)) // ❌ stateden manuel silme yok
// Update attendance record for kicked participant // attendance update kısmı aynı kalabilir
setAttendanceRecords((prev) => setAttendanceRecords((prev) =>
prev.map((r) => { prev.map((r) => {
if (r.studentId === participantId && !r.leaveTime) { if (r.studentId === participantId && !r.leaveTime) {
@ -580,17 +590,31 @@ const RoomDetail: React.FC = () => {
const handleStartScreenShare = async () => { const handleStartScreenShare = async () => {
try { try {
const stream = await navigator.mediaDevices.getDisplayMedia({ // 1. sadece ekran videosu al
const screen = await navigator.mediaDevices.getDisplayMedia({
video: true, 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) setIsScreenSharing(true)
setScreenSharer(user.name) setScreenSharer(user.name)
webRTCServiceRef.current?.addStreamToPeers(screen)
// Handle stream end // Handle stream end
stream.getVideoTracks()[0].onended = () => { screen.getVideoTracks()[0].onended = () => {
handleStopScreenShare() handleStopScreenShare()
} }
} catch (error) { } catch (error) {
@ -600,7 +624,11 @@ const RoomDetail: React.FC = () => {
const handleStopScreenShare = () => { const handleStopScreenShare = () => {
if (screenStream) { if (screenStream) {
screenStream.getTracks().forEach((track) => track.stop()) // PeerConnectionstan kaldır
screenStream.getTracks().forEach((track) => {
webRTCServiceRef.current?.removeTrackFromPeers(track)
track.stop()
})
setScreenStream(undefined) setScreenStream(undefined)
} }
setIsScreenSharing(false) setIsScreenSharing(false)