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;
|
_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ı aç
|
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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔑 Participant’i 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 event’i
|
// 🔑 3. ParticipantLeft event’i
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
// ❌ state’den 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())
|
// PeerConnections’tan kaldır
|
||||||
|
screenStream.getTracks().forEach((track) => {
|
||||||
|
webRTCServiceRef.current?.removeTrackFromPeers(track)
|
||||||
|
track.stop()
|
||||||
|
})
|
||||||
setScreenStream(undefined)
|
setScreenStream(undefined)
|
||||||
}
|
}
|
||||||
setIsScreenSharing(false)
|
setIsScreenSharing(false)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue