Classroom SignalR ve WebRtc güvenlik düzenlemesi
This commit is contained in:
parent
7c882cb5d8
commit
a09f65f53d
8 changed files with 343 additions and 202 deletions
|
|
@ -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ı 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)
|
||||
{
|
||||
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())
|
||||
.SendAsync("AttendanceUpdated", attendance);
|
||||
await CloseAttendanceAsync(attendance);
|
||||
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);
|
||||
}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
video: enableVideo
|
||||
? {
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
frameRate: { ideal: 30 },
|
||||
},
|
||||
audio: {
|
||||
}
|
||||
: 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)
|
||||
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)
|
||||
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]
|
||||
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]
|
||||
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()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue