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;
}
#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")]
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<ClassroomSettingsDto>(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ı
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);
}
// 🔑 Participanti 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 eventi
@ -510,6 +501,8 @@ public class ClassroomHub : Hub
}
}
public class SignalingMessageDto
{
public string Type { get; set; } // offer, answer, ice-candidate

View file

@ -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<ParticipantGridProps> = ({
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)) {

View file

@ -23,9 +23,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
userName,
isAudioEnabled = true,
isVideoEnabled = true,
onToggleAudio,
onToggleVideo,
onLeaveCall,
}) => {
const videoRef = useRef<HTMLVideoElement>(null)
@ -36,24 +33,26 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
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 (
<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
ref={videoRef}
autoPlay
playsInline
muted={isLocal}
className="w-full h-full object-cover"
style={{ display: isVideoEnabled ? 'block' : 'none' }}
/>
{/* User name overlay */}
@ -61,7 +60,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
{userName} {isLocal && '(You)'}
</div>
{/* Video disabled overlay */}
{/* Video kapalıysa avatar/placeholder göster */}
{!isVideoEnabled && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-800">
<div className="text-center text-white">

View file

@ -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

View file

@ -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

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 * 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<void> {
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<void> {
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
}

View file

@ -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<MediaStream> {
/**
* Local stream'i başlatır. Kamera/mikrofon ayarlarını parametreden alır.
*/
async initializeLocalStream(enableAudio: boolean, enableVideo: boolean): Promise<MediaStream> {
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<RTCSessionDescriptionInit> {
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<RTCSessionDescriptionInit> {
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<void> {
@ -104,30 +123,74 @@ export class WebRTCService {
}
async addIceCandidate(userId: string, candidate: RTCIceCandidateInit): Promise<void> {
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<void> {
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<void> {
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()
}
}
})
})
}
}

View file

@ -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<ClassroomDto>(newClassSession)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [participants, setParticipants] = useState<ClassroomParticipantDto[]>([])
const [localStream, setLocalStream] = useState<MediaStream>()
const [localStream, setLocalStream] = useState<MediaStream | null>(null)
const [isAudioEnabled, setIsAudioEnabled] = useState(true)
const [isVideoEnabled, setIsVideoEnabled] = useState(true)
const [attendanceRecords, setAttendanceRecords] = useState<ClassroomAttendanceDto[]>([])
@ -115,7 +115,7 @@ const RoomDetail: React.FC = () => {
const [isFullscreen, setIsFullscreen] = useState(false)
const [activeSidePanel, setActiveSidePanel] = useState<SidePanelType>(null)
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>(
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
// ❌ stateden 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())
// PeerConnectionstan kaldır
screenStream.getTracks().forEach((track) => {
webRTCServiceRef.current?.removeTrackFromPeers(track)
track.stop()
})
setScreenStream(undefined)
}
setIsScreenSharing(false)