573 lines
16 KiB
TypeScript
573 lines
16 KiB
TypeScript
import { ROUTES_ENUM } from '@/routes/route.constant'
|
||
import { store } from '@/store/store'
|
||
import * as signalR from '@microsoft/signalr'
|
||
import { toast } from '@/components/ui'
|
||
import Notification from '@/components/ui/Notification'
|
||
import { VideoroomAttendanceDto, VideoroomChatDto } from '@/proxy/videoroom/models'
|
||
|
||
export class SignalRService {
|
||
private connection!: signalR.HubConnection
|
||
private isConnected: boolean = false
|
||
private currentSessionId?: string
|
||
private isKicked: boolean = false
|
||
|
||
private onAttendanceUpdate?: (record: VideoroomAttendanceDto) => void
|
||
private onParticipantJoined?: (
|
||
userId: string,
|
||
name: string,
|
||
isTeacher: boolean,
|
||
isActive: boolean,
|
||
) => void
|
||
private onParticipantLeft?: (payload: {
|
||
userId: string
|
||
sessionId: string
|
||
userName: string
|
||
}) => void
|
||
private onChatMessage?: (message: VideoroomChatDto) => void
|
||
private onParticipantMuted?: (userId: string, isMuted: boolean) => void
|
||
private onHandRaiseReceived?: (studentId: string) => void
|
||
private onHandRaiseDismissed?: (studentId: string) => void
|
||
private onOfferReceived?: (fromUserId: string, offer: RTCSessionDescriptionInit) => void
|
||
private onAnswerReceived?: (fromUserId: string, answer: RTCSessionDescriptionInit) => void
|
||
private onIceCandidateReceived?: (fromUserId: string, candidate: RTCIceCandidateInit) => void
|
||
private onForceCleanup?: () => void
|
||
|
||
constructor() {
|
||
const { auth } = store.getState()
|
||
|
||
this.connection = new signalR.HubConnectionBuilder()
|
||
.withUrl(`${import.meta.env.VITE_API_URL}/videoroomhub`, {
|
||
accessTokenFactory: () => auth.session.token || '',
|
||
})
|
||
.configureLogging(signalR.LogLevel.Information)
|
||
.build()
|
||
|
||
this.setupEventHandlers()
|
||
}
|
||
|
||
private setupEventHandlers() {
|
||
if (!this.connection) return
|
||
|
||
this.connection.on('AttendanceUpdated', (record: VideoroomAttendanceDto) => {
|
||
this.onAttendanceUpdate?.(record)
|
||
})
|
||
|
||
this.connection.on(
|
||
'ParticipantJoined',
|
||
(userId: string, name: string, isTeacher: boolean, isActive: boolean) => {
|
||
this.onParticipantJoined?.(userId, name, isTeacher, isActive)
|
||
},
|
||
)
|
||
|
||
this.connection.on(
|
||
'ParticipantLeft',
|
||
(payload: { userId: string; sessionId: string; userName: string }) => {
|
||
this.onParticipantLeft?.(payload)
|
||
},
|
||
)
|
||
|
||
this.connection.on('ChatMessage', (message: any) => {
|
||
this.onChatMessage?.(message)
|
||
})
|
||
|
||
this.connection.on('ParticipantMuted', (userId: string, isMuted: boolean) => {
|
||
this.onParticipantMuted?.(userId, isMuted)
|
||
})
|
||
|
||
this.connection.on('HandRaiseReceived', (payload: any) => {
|
||
this.onHandRaiseReceived?.(payload.studentId)
|
||
})
|
||
|
||
this.connection.on('HandRaiseDismissed', (payload: any) => {
|
||
this.onHandRaiseDismissed?.(payload.studentId)
|
||
})
|
||
|
||
this.connection.on('ReceiveOffer', (fromUserId: string, offer: RTCSessionDescriptionInit) => {
|
||
this.onOfferReceived?.(fromUserId, offer)
|
||
})
|
||
|
||
this.connection.on('ReceiveAnswer', (fromUserId: string, answer: RTCSessionDescriptionInit) => {
|
||
this.onAnswerReceived?.(fromUserId, answer)
|
||
})
|
||
|
||
this.connection.on(
|
||
'ReceiveIceCandidate',
|
||
(fromUserId: string, candidate: RTCIceCandidateInit) => {
|
||
this.onIceCandidateReceived?.(fromUserId, candidate)
|
||
},
|
||
)
|
||
|
||
this.connection.onreconnected(async () => {
|
||
this.isConnected = true
|
||
toast.push(<Notification title="🔄 Bağlantı tekrar kuruldu" type="success" />, {
|
||
placement: 'top-end',
|
||
})
|
||
|
||
if (this.currentSessionId && store.getState().auth.user) {
|
||
const u = store.getState().auth.user
|
||
await this.joinClass(this.currentSessionId, u.id, u.name, u.role === 'teacher', true)
|
||
}
|
||
})
|
||
|
||
this.connection.onclose(async () => {
|
||
if (this.isKicked) {
|
||
toast.push(
|
||
<Notification title="⚠️ Bağlantı koptu, yeniden bağlanılıyor..." type="warning" />,
|
||
{ placement: 'top-end' },
|
||
)
|
||
this.isConnected = false
|
||
this.currentSessionId = undefined
|
||
return
|
||
}
|
||
|
||
this.isConnected = false
|
||
try {
|
||
if (this.currentSessionId) {
|
||
await this.connection.invoke('LeaveClass', this.currentSessionId)
|
||
}
|
||
} finally {
|
||
this.currentSessionId = undefined
|
||
}
|
||
})
|
||
|
||
this.connection.on('Error', (message: string) => {
|
||
toast.push(<Notification title={`❌ Hata: ${message}`} type="danger" />, {
|
||
placement: 'top-end',
|
||
})
|
||
})
|
||
|
||
this.connection.on('Warning', (message: string) => {
|
||
toast.push(<Notification title={`⚠️ Uyarı: ${message}`} type="warning" />, {
|
||
placement: 'top-end',
|
||
})
|
||
})
|
||
|
||
this.connection.on('Info', (message: string) => {
|
||
toast.push(<Notification title={`ℹ️ Bilgi: ${message}`} type="info" />, {
|
||
placement: 'top-end',
|
||
})
|
||
})
|
||
|
||
this.connection.onreconnecting(() => {
|
||
if (this.isKicked) {
|
||
toast.push(
|
||
<Notification
|
||
title="❌ Sınıftan çıkarıldığınız için yeniden bağlanma engellendi"
|
||
type="danger"
|
||
/>,
|
||
)
|
||
this.connection.stop()
|
||
throw new Error('Reconnect blocked after kick')
|
||
}
|
||
})
|
||
|
||
this.connection.on('ForceDisconnect', async (message: string) => {
|
||
this.isKicked = true
|
||
toast.push(<Notification title={`❌ Sınıftan çıkarıldınız: ${message}`} type="danger" />, {
|
||
placement: 'top-end',
|
||
})
|
||
|
||
if (this.onForceCleanup) {
|
||
this.onForceCleanup()
|
||
}
|
||
|
||
try {
|
||
await this.connection.stop()
|
||
} catch {}
|
||
|
||
this.isConnected = false
|
||
|
||
if (this.currentSessionId && store.getState().auth.user) {
|
||
this.onParticipantLeft?.({
|
||
userId: store.getState().auth.user.id,
|
||
sessionId: this.currentSessionId,
|
||
userName: store.getState().auth.user.name,
|
||
})
|
||
}
|
||
|
||
this.currentSessionId = undefined
|
||
window.location.href = ROUTES_ENUM.protected.admin.videoroom.roomList
|
||
})
|
||
}
|
||
|
||
async start(): Promise<void> {
|
||
try {
|
||
const startPromise = this.connection.start()
|
||
const timeout = new Promise((_, reject) =>
|
||
setTimeout(() => reject(new Error('Bağlantı zaman aşımına uğradı')), 10000),
|
||
)
|
||
|
||
await Promise.race([startPromise, timeout])
|
||
this.isConnected = true
|
||
toast.push(<Notification title="✅ Bağlantı kuruldu" type="success" />, {
|
||
placement: 'top-end',
|
||
})
|
||
} catch {
|
||
toast.push(
|
||
<Notification
|
||
title="⚠️ Sunucuya bağlanılamadı. Lütfen sayfayı yenileyin veya internet bağlantınızı kontrol edin."
|
||
type="danger"
|
||
/>,
|
||
{ placement: 'top-end' },
|
||
)
|
||
this.isConnected = false
|
||
}
|
||
}
|
||
|
||
async joinClass(
|
||
sessionId: string,
|
||
userId: string,
|
||
userName: string,
|
||
isTeacher: boolean,
|
||
isActive: boolean,
|
||
): Promise<void> {
|
||
if (!this.isConnected) {
|
||
toast.push(
|
||
<Notification
|
||
title="⚠️ Bağlantı yok. Sınıfa katılmadan önce bağlantıyı kontrol edin."
|
||
type="warning"
|
||
/>,
|
||
)
|
||
return
|
||
}
|
||
|
||
this.currentSessionId = sessionId
|
||
try {
|
||
await this.connection.invoke('JoinClass', sessionId, userId, userName, isTeacher, isActive)
|
||
} catch {
|
||
toast.push(<Notification title="❌ Sınıfa katılamadı" type="danger" />, {
|
||
placement: 'top-end',
|
||
})
|
||
}
|
||
}
|
||
|
||
async leaveClass(sessionId: string): Promise<void> {
|
||
const { auth } = store.getState()
|
||
|
||
if (!this.isConnected) {
|
||
this.onParticipantLeft?.({ userId: auth.user.id, sessionId, userName: auth.user.name })
|
||
return
|
||
}
|
||
|
||
try {
|
||
await this.connection.invoke('LeaveClass', sessionId)
|
||
this.currentSessionId = undefined
|
||
} catch {
|
||
toast.push(<Notification title="⚠️ Çıkış başarısız" type="warning" />, {
|
||
placement: 'top-end',
|
||
})
|
||
}
|
||
}
|
||
|
||
async sendChatMessage(
|
||
sessionId: string,
|
||
senderId: string,
|
||
senderName: string,
|
||
message: string,
|
||
isTeacher: boolean,
|
||
): Promise<void> {
|
||
if (!this.isConnected) {
|
||
const chatMessage: VideoroomChatDto = {
|
||
id: crypto.randomUUID(),
|
||
sessionId,
|
||
senderId,
|
||
senderName,
|
||
message,
|
||
timestamp: new Date().toISOString(),
|
||
isTeacher,
|
||
messageType: 'public',
|
||
}
|
||
setTimeout(() => {
|
||
this.onChatMessage?.(chatMessage)
|
||
}, 100)
|
||
return
|
||
}
|
||
|
||
try {
|
||
await this.connection.invoke(
|
||
'SendChatMessage',
|
||
sessionId,
|
||
senderId,
|
||
senderName,
|
||
message,
|
||
isTeacher,
|
||
'public',
|
||
)
|
||
} catch {
|
||
toast.push(<Notification title="❌ Mesaj gönderilemedi" type="danger" />, {
|
||
placement: 'top-end',
|
||
})
|
||
}
|
||
}
|
||
|
||
async sendPrivateMessage(
|
||
sessionId: string,
|
||
senderId: string,
|
||
senderName: string,
|
||
message: string,
|
||
recipientId: string,
|
||
recipientName: string,
|
||
isTeacher: boolean,
|
||
): Promise<void> {
|
||
if (!this.isConnected) {
|
||
const chatMessage: VideoroomChatDto = {
|
||
id: crypto.randomUUID(),
|
||
sessionId,
|
||
senderId,
|
||
senderName,
|
||
message,
|
||
timestamp: new Date().toISOString(),
|
||
isTeacher,
|
||
recipientId,
|
||
recipientName,
|
||
messageType: 'private',
|
||
}
|
||
setTimeout(() => {
|
||
this.onChatMessage?.(chatMessage)
|
||
}, 100)
|
||
return
|
||
}
|
||
|
||
try {
|
||
await this.connection.invoke(
|
||
'SendPrivateMessage',
|
||
sessionId,
|
||
senderId,
|
||
senderName,
|
||
message,
|
||
recipientId,
|
||
recipientName,
|
||
isTeacher,
|
||
'private',
|
||
)
|
||
} catch {
|
||
toast.push(<Notification title="❌ Özel mesaj gönderilemedi" type="danger" />, {
|
||
placement: 'top-end',
|
||
})
|
||
}
|
||
}
|
||
|
||
async sendAnnouncement(
|
||
sessionId: string,
|
||
senderId: string,
|
||
senderName: string,
|
||
message: string,
|
||
isTeacher: boolean,
|
||
): Promise<void> {
|
||
if (!this.isConnected) {
|
||
const chatMessage: VideoroomChatDto = {
|
||
id: crypto.randomUUID(),
|
||
sessionId,
|
||
senderId,
|
||
senderName,
|
||
message,
|
||
timestamp: new Date().toISOString(),
|
||
isTeacher,
|
||
messageType: 'announcement',
|
||
}
|
||
setTimeout(() => {
|
||
this.onChatMessage?.(chatMessage)
|
||
}, 100)
|
||
return
|
||
}
|
||
|
||
try {
|
||
await this.connection.invoke(
|
||
'SendAnnouncement',
|
||
sessionId,
|
||
senderId,
|
||
senderName,
|
||
message,
|
||
isTeacher,
|
||
)
|
||
} catch {
|
||
toast.push(<Notification title="❌ Duyuru gönderilemedi" type="danger" />, {
|
||
placement: 'top-end',
|
||
})
|
||
}
|
||
}
|
||
|
||
async muteParticipant(
|
||
sessionId: string,
|
||
userId: string,
|
||
isMuted: boolean,
|
||
isTeacher: boolean,
|
||
): Promise<void> {
|
||
if (!this.isConnected) {
|
||
setTimeout(() => {
|
||
this.onParticipantMuted?.(userId, isMuted)
|
||
}, 100)
|
||
return
|
||
}
|
||
|
||
try {
|
||
await this.connection.invoke('MuteParticipant', sessionId, userId, isMuted, isTeacher)
|
||
} catch {
|
||
toast.push(<Notification title="⚠️ Katılımcı susturulamadı" type="warning" />, {
|
||
placement: 'top-end',
|
||
})
|
||
}
|
||
}
|
||
|
||
async raiseHand(sessionId: string, studentId: string, studentName: string): Promise<void> {
|
||
if (!this.isConnected) {
|
||
setTimeout(() => {
|
||
this.onHandRaiseReceived?.(studentId)
|
||
}, 100)
|
||
return
|
||
}
|
||
|
||
try {
|
||
await this.connection.invoke('RaiseHand', sessionId, studentId, studentName)
|
||
} catch {
|
||
toast.push(<Notification title="❌ El kaldırma başarısız" type="danger" />, {
|
||
placement: 'top-end',
|
||
})
|
||
}
|
||
}
|
||
|
||
async kickParticipant(sessionId: string, participantId: string, userName: string): Promise<void> {
|
||
if (!this.isConnected) {
|
||
setTimeout(() => {
|
||
this.onParticipantLeft?.({ userId: participantId, sessionId, userName })
|
||
}, 100)
|
||
return
|
||
}
|
||
|
||
try {
|
||
await this.connection.invoke('KickParticipant', sessionId, participantId)
|
||
} catch {
|
||
toast.push(<Notification title="❌ Katılımcı atılamadı" type="danger" />, {
|
||
placement: 'top-end',
|
||
})
|
||
}
|
||
}
|
||
|
||
async approveHandRaise(sessionId: string, studentId: string): Promise<void> {
|
||
if (!this.isConnected) {
|
||
setTimeout(() => {
|
||
this.onHandRaiseDismissed?.(studentId)
|
||
}, 100)
|
||
return
|
||
}
|
||
|
||
try {
|
||
await this.connection.invoke('ApproveHandRaise', sessionId, studentId)
|
||
} catch {
|
||
toast.push(<Notification title="⚠️ El kaldırma onayı başarısız" type="warning" />, {
|
||
placement: 'top-end',
|
||
})
|
||
}
|
||
}
|
||
|
||
async dismissHandRaise(sessionId: string, studentId: string): Promise<void> {
|
||
if (!this.isConnected) {
|
||
setTimeout(() => {
|
||
this.onHandRaiseDismissed?.(studentId)
|
||
}, 100)
|
||
return
|
||
}
|
||
|
||
try {
|
||
await this.connection.invoke('DismissHandRaise', sessionId, studentId)
|
||
} catch {
|
||
toast.push(<Notification title="⚠️ El indirme başarısız" type="warning" />, {
|
||
placement: 'top-end',
|
||
})
|
||
}
|
||
}
|
||
|
||
async sendOffer(sessionId: string, targetUserId: string, offer: RTCSessionDescriptionInit) {
|
||
if (!this.isConnected) return
|
||
await this.connection.invoke('SendOffer', sessionId, targetUserId, offer)
|
||
}
|
||
|
||
async sendAnswer(sessionId: string, targetUserId: string, answer: RTCSessionDescriptionInit) {
|
||
if (!this.isConnected) return
|
||
await this.connection.invoke('SendAnswer', sessionId, targetUserId, answer)
|
||
}
|
||
|
||
async sendIceCandidate(sessionId: string, targetUserId: string, candidate: RTCIceCandidateInit) {
|
||
if (!this.isConnected) return
|
||
await this.connection.invoke('SendIceCandidate', sessionId, targetUserId, candidate)
|
||
}
|
||
|
||
setExistingParticipantsHandler(callback: (participants: any[]) => void) {
|
||
this.connection.on('ExistingParticipants', callback)
|
||
}
|
||
|
||
setAttendanceUpdatedHandler(callback: (record: VideoroomAttendanceDto) => void) {
|
||
this.onAttendanceUpdate = callback
|
||
}
|
||
|
||
setParticipantJoinHandler(
|
||
callback: (userId: string, name: string, isTeacher: boolean, isActive: boolean) => void,
|
||
) {
|
||
this.onParticipantJoined = callback
|
||
}
|
||
|
||
setParticipantLeaveHandler(
|
||
callback: (payload: { userId: string; sessionId: string; userName: string }) => void,
|
||
) {
|
||
this.onParticipantLeft = callback
|
||
}
|
||
|
||
setChatMessageReceivedHandler(callback: (message: VideoroomChatDto) => void) {
|
||
this.onChatMessage = callback
|
||
}
|
||
|
||
setParticipantMutedHandler(callback: (userId: string, isMuted: boolean) => void) {
|
||
this.onParticipantMuted = callback
|
||
}
|
||
|
||
setHandRaiseReceivedHandler(callback: (studentId: string) => void) {
|
||
this.onHandRaiseReceived = callback
|
||
}
|
||
|
||
setHandRaiseDismissedHandler(callback: (studentId: string) => void) {
|
||
this.onHandRaiseDismissed = callback
|
||
}
|
||
|
||
setOfferReceivedHandler(
|
||
callback: (fromUserId: string, offer: RTCSessionDescriptionInit) => void,
|
||
) {
|
||
this.onOfferReceived = callback
|
||
}
|
||
|
||
setAnswerReceivedHandler(
|
||
callback: (fromUserId: string, answer: RTCSessionDescriptionInit) => void,
|
||
) {
|
||
this.onAnswerReceived = callback
|
||
}
|
||
|
||
setIceCandidateReceivedHandler(
|
||
callback: (fromUserId: string, candidate: RTCIceCandidateInit) => void,
|
||
) {
|
||
this.onIceCandidateReceived = callback
|
||
}
|
||
|
||
async disconnect(): Promise<void> {
|
||
if (this.isConnected && this.currentSessionId) {
|
||
try {
|
||
await this.connection.invoke('LeaveClass', this.currentSessionId)
|
||
} catch {
|
||
toast.push(<Notification title="⚠️ Bağlantı koparılırken hata" type="warning" />, {
|
||
placement: 'top-end',
|
||
})
|
||
}
|
||
}
|
||
if (this.connection) {
|
||
await this.connection.stop()
|
||
}
|
||
this.isConnected = false
|
||
this.currentSessionId = undefined
|
||
}
|
||
|
||
getConnectionState(): boolean {
|
||
return this.isConnected
|
||
}
|
||
|
||
setForceCleanupHandler(callback: () => void) {
|
||
this.onForceCleanup = callback
|
||
}
|
||
}
|