diff --git a/frontend/src/core/api/authApi.ts b/frontend/src/core/api/authApi.ts index 28311a7..ed9ea77 100644 --- a/frontend/src/core/api/authApi.ts +++ b/frontend/src/core/api/authApi.ts @@ -139,4 +139,8 @@ export async function ensureKeysOnLogin(password: string, token: string): Promis export function restoreKeys() { currentPublicKey = ub64(localStorage.getItem("publicKey")!); currentPrivateKey = ub64(localStorage.getItem("privateKey")!); +} + +export function getAuthToken(): string | null { + return localStorage.getItem("authToken"); } \ No newline at end of file diff --git a/frontend/src/core/calls/signaling.ts b/frontend/src/core/calls/signaling.ts index 19fcf3f..bc158c4 100644 --- a/frontend/src/core/calls/signaling.ts +++ b/frontend/src/core/calls/signaling.ts @@ -25,7 +25,6 @@ export class CallSignalingHandler { handleWebSocketMessage(message: CallSignalingMessage) { const { data } = message; if (!data) { - console.warn("Received call signaling message with no data:", message); return; } diff --git a/frontend/src/core/calls/webrtc.ts b/frontend/src/core/calls/webrtc.ts index 6d67e47..9e46562 100644 --- a/frontend/src/core/calls/webrtc.ts +++ b/frontend/src/core/calls/webrtc.ts @@ -1,4 +1,4 @@ -import { getAuthHeaders } from "@/core/api/authApi"; +import { getAuthHeaders, getAuthToken } from "@/core/api/authApi"; import type { CallSignalingMessage, IceServersResponse, WrappedSessionKeyPayload } from "@/core/types"; import { request } from "@/core/websocket"; import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender, rotateCallSessionKey } from "./encryption"; @@ -12,315 +12,785 @@ const DEFAULT_ICE_SERVERS = [{ urls: "stun:fromchat.ru:3478" }]; const KEY_ROTATION_INTERVAL = 10 * 60 * 1000; // 10 minutes const NEGOTIATION_DELAY = 100; // ms -export interface WebRTCCall { - peerConnection: RTCPeerConnection; - localStream: MediaStream | null; - remoteStream: MediaStream | null; - localVideoStream: MediaStream | null; - screenShareStream: MediaStream | null; - isInitiator: boolean; - remoteUserId: number; - remoteUsername: string; - isEnding?: boolean; - isMuted?: boolean; - isLocalVideoEnabled: boolean; - isScreenSharing: boolean; - isRemoteScreenSharing: boolean; // Track remote screen share state from signaling - isRemoteVideoEnabled: boolean; // Track remote video state from signaling - isNegotiating?: boolean; +/** + * WebRTC Call class for managing individual call instances + */ +export class WebRTCCall { + private _peerConnection!: RTCPeerConnection; + localStream: MediaStream | null = null; + private localVideoStream: MediaStream | null = null; + private screenShareStream: MediaStream | null = null; + readonly remoteUserId: number; + remoteUsername: string = ""; + isEnding?: boolean = false; + private isMuted?: boolean = false; + private isLocalVideoEnabled: boolean = false; + private isScreenSharing: boolean = false; + private isRemoteScreenSharing: boolean = false; // Track remote screen share state from signaling + private isRemoteVideoEnabled: boolean = false; // Track remote video state from signaling + isNegotiating?: boolean = false; // Insertable Streams E2EE - sessionKey?: Uint8Array | null; - sessionCryptoKey?: CryptoKey | null; - sessionId: string; - keyRotationTimer?: NodeJS.Timeout; - lastKeyRotation?: number; - transformedSenders: Set; - transformedReceivers: Set; + private _sessionKey: Uint8Array | null = null; + private _sessionId: string; + private keyRotationTimer?: NodeJS.Timeout; + private transformedSenders: Set = new Set(); + private transformedReceivers: Set = new Set(); // Track specific senders for proper routing when both video and screen share are active - videoSender?: RTCRtpSender | null; - screenShareSender?: RTCRtpSender | null; + private videoSender?: RTCRtpSender | null = null; + private screenShareSender?: RTCRtpSender | null = null; // Track the number of video tracks received for each type - receivedVideoTrackCount: number; - receivedScreenShareTrackCount: number; + private receivedVideoTrackCount: number = 0; + private receivedScreenShareTrackCount: number = 0; + + // ------------------- + // Getters and setters + // ------------------- + + get peerConnection(): RTCPeerConnection { + return this._peerConnection; + } + + private set peerConnection(value: RTCPeerConnection) { + this._peerConnection = value; + } + + get sessionId(): string { + return this._sessionId; + } + + private set sessionId(value: string) { + this._sessionId = value; + } + + get sessionKey(): Uint8Array | null { + return this._sessionKey; + } + + private set sessionKey(value: Uint8Array | null) { + this._sessionKey = value; + } + + + // ------------------- + // Core initialization + // ------------------- + + constructor(userId: number) { + this.remoteUserId = userId; + this._sessionId = crypto.randomUUID(); + } + + /** + * Initializes the peer connection with proper ICE servers and sets up event listeners + */ + async initialize(): Promise { + const iceServers = await this.getIceServers(); + + // Create peer connection with proper ICE servers + this.peerConnection = new RTCPeerConnection({ + iceServers + }); + + this.setupEventListeners(); + } + + /** + * Gets ICE servers from backend with fallback + */ + private async getIceServers(): Promise { + try { + const response = await fetch("/api/webrtc/ice", { + headers: getAuthHeaders(getAuthToken()!) + }); + + if (response.ok) { + const data = await response.json() as IceServersResponse; + return data.iceServers || []; + } else { + console.warn("Failed to fetch ICE servers:", response.status, response.statusText); + } + } catch (error) { + console.warn("Failed to fetch ICE servers:", error); + } + + // Fallback to STUN only if backend fails + return DEFAULT_ICE_SERVERS; + } + + /** + * Sets up all peer connection event listeners + */ + private setupEventListeners(): void { + // Add ICE candidate event listener for sending + this.peerConnection.addEventListener("icecandidate", async (event) => { + if (event.candidate) { + const { candidate, sdpMLineIndex, sdpMid } = event.candidate; + + try { + await sendSignalingMessage({ + type: "call_ice_candidate", + fromUserId: 0, + toUserId: this.remoteUserId, + data: { candidate, sdpMLineIndex, sdpMid } + }); + } catch (error) { + console.error("Failed to send ICE candidate:", error); + } + } + }); + + this.peerConnection.addEventListener("iceconnectionstatechange", () => { + // ICE connection state changed + }); + + this.peerConnection.addEventListener("signalingstatechange", () => { + // Signaling state changed + }); + + // Handle renegotiation when tracks are added/removed + this.peerConnection.addEventListener("negotiationneeded", async () => { + try { + // Prevent multiple simultaneous negotiations + if (this.isNegotiating) { + return; + } + + // Skip if we're in "stable" state and haven't finished the initial handshake + if (this.peerConnection.signalingState !== "stable") { + return; + } + + this.isNegotiating = true; + + const offer = await this.peerConnection.createOffer(); + await this.peerConnection.setLocalDescription(offer); + + await sendSignalingMessage({ + type: "call_offer", + fromUserId: 0, + toUserId: this.remoteUserId, + data: offer + }); + + this.isNegotiating = false; + } catch (error) { + console.error("Failed to handle negotiation:", error); + this.isNegotiating = false; + } + }); + + // Handle remote stream + this.peerConnection.addEventListener("track", async (event) => { + const [remoteStream] = event.streams; + if (remoteStream) { + const track = event.track; + + // Apply E2EE transform to all tracks - video now uses header-preserving encryption + if (this.sessionKey && window.RTCRtpScriptTransform) { + try { + const receiver = this.peerConnection.getReceivers().find(r => r.track === track); + if (receiver && !this.transformedReceivers.has(receiver)) { + const key = await importAesGcmKey(this.sessionKey); + receiver.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'decrypt', sessionId: this.sessionId }); + this.transformedReceivers.add(receiver); + } + } catch (error) { + console.error("Failed to apply E2EE to received track:", error); + } + } + + // Determine stream type based on track kind and signaling state + if (track.kind === "video") { + let isScreenShare = false; + let isVideo = false; + + if (this.isRemoteScreenSharing && this.isRemoteVideoEnabled) { + // Both active - route based on which one we haven't received yet + // Simple logic: if we haven't received video yet, this is video + // if we haven't received screen share yet, this is screen share + if (this.receivedVideoTrackCount === 0) { + isVideo = true; + this.receivedVideoTrackCount++; + } else if (this.receivedScreenShareTrackCount === 0) { + isScreenShare = true; + this.receivedScreenShareTrackCount++; + } else { + // Both already received - this might be a track replacement + isScreenShare = true; + } + } else if (this.isRemoteScreenSharing) { + isScreenShare = true; + this.receivedScreenShareTrackCount++; + } else if (this.isRemoteVideoEnabled) { + isVideo = true; + this.receivedVideoTrackCount++; + } + + if (isScreenShare) { + if (callbacks.onRemoteScreenShare) { + callbacks.onRemoteScreenShare(this.remoteUserId, remoteStream); + } + } else if (isVideo) { + if (callbacks.onRemoteVideoStream) { + callbacks.onRemoteVideoStream(this.remoteUserId, remoteStream); + } + } + } else if (track.kind === "audio") { + // Handle remote audio (existing behavior) + if (callbacks.onRemoteStream) { + callbacks.onRemoteStream(this.remoteUserId, remoteStream); + } + } + } + }); + + // Handle connection state changes + this.peerConnection.addEventListener("connectionstatechange", () => { + // WebRTC connection state changed + if (callbacks.onCallStateChange) { + callbacks.onCallStateChange(this.remoteUserId, this.peerConnection.connectionState); + } + + // Clean up only on permanent failures + // Don't end on "disconnected" - ICE can recover from temporary disconnections + if (this.peerConnection.connectionState === "failed" || + this.peerConnection.connectionState === "closed") { + // Only send end call message if we're not already cleaning up + if (!this.isEnding) { + this.isEnding = true; + endCall(this.remoteUserId); + } + } + }); + } + + + // ---------- + // Management + // ---------- + + /** + * Toggles mute state for this call + */ + toggleMute(): boolean { + if (!this.localStream) { + return false; + } + + if (!this.isMuted) { + // Mute: Stop the track completely (no green dot) + const audioTrack = this.localStream.getAudioTracks()[0]; + if (audioTrack) { + audioTrack.stop(); + this.localStream.removeTrack(audioTrack); + } + + // Create a silent audio track using Web Audio API + const AudioContextClass = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; + const audioContext = new AudioContextClass(); + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + // Set gain to 0 (silent) + gainNode.gain.setValueAtTime(0, audioContext.currentTime); + + // Connect nodes + oscillator.connect(gainNode); + + // Create a MediaStreamDestination to get a MediaStream + const destination = audioContext.createMediaStreamDestination(); + gainNode.connect(destination); + + // Start the oscillator (but it's silent due to gain = 0) + oscillator.start(); + + // Add the silent track to maintain WebRTC connection + const silentTrack = destination.stream.getAudioTracks()[0]; + if (silentTrack) { + this.localStream.addTrack(silentTrack); + } + + this.isMuted = true; + return true; // Muted + } else { + // Unmute: Re-enable microphone by getting new audio stream + navigator.mediaDevices.getUserMedia({ audio: true, video: false }) + .then(newStream => { + // Remove any existing audio tracks from the stream + this.localStream!.getAudioTracks().forEach(track => track.stop()); + + // Get the new active track + const newAudioTrack = newStream.getAudioTracks()[0]; + + // Replace the track in the peer connection + const sender = this.peerConnection.getSenders().find(s => + s.track && s.track.kind === 'audio' + ); + + if (sender) { + // Replace the track in the existing sender + sender.replaceTrack(newAudioTrack); + } else { + // Add the track to the peer connection if no sender exists + this.peerConnection.addTrack(newAudioTrack, this.localStream!); + } + + // Add the track to the local stream + this.localStream!.addTrack(newAudioTrack); + + this.isMuted = false; + }) + .catch(error => { + console.error("Failed to re-enable microphone:", error); + }); + return false; // Unmuted + } + } + + /** + * Toggles video for this call + */ + async toggleVideo(): Promise { + if (!this.isLocalVideoEnabled) { + // Enable video + try { + const videoStream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: false + }); + + this.localVideoStream = videoStream; + this.isLocalVideoEnabled = true; + + // Add video track to peer connection + const videoTrack = videoStream.getVideoTracks()[0]; + const sender = this.peerConnection.addTrack(videoTrack, videoStream); + this.videoSender = sender; + + // Apply E2EE transform with header-preserving encryption for video + if (this.sessionKey && window.RTCRtpScriptTransform) { + try { + const key = await importAesGcmKey(this.sessionKey); + const sender = this.peerConnection.getSenders().find(s => s.track === videoTrack); + if (sender && !this.transformedSenders.has(sender)) { + sender.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: "encrypt", sessionId: this.sessionId }); + this.transformedSenders.add(sender); + } + } catch (error) { + console.error("Failed to apply E2EE to video:", error); + throw error; // Fail securely + } + } + + // Notify local video stream handler + if (callbacks.onLocalVideoStream) { + callbacks.onLocalVideoStream(this.remoteUserId, videoStream); + } + + // Send signaling message to notify remote peer + await sendSignalingMessage({ + type: "call_video_toggle", + fromUserId: 0, + toUserId: this.remoteUserId, + data: { enabled: true } + }); + + return true; + } catch (error) { + console.error("Failed to enable video:", error); + return false; + } + } else { + // Disable video + if (this.localVideoStream) { + this.localVideoStream.getTracks().forEach(track => { + track.stop(); + // Remove track from peer connection + const senders = this.peerConnection.getSenders(); + const videoSender = senders.find(s => s.track === track); + if (videoSender) { + this.peerConnection.removeTrack(videoSender); + this.transformedSenders.delete(videoSender); + // Clear sender reference + if (this.videoSender === videoSender) { + this.videoSender = null; + } + } + }); + this.localVideoStream = null; + } + + this.isLocalVideoEnabled = false; + + // Notify local video stream handler + if (callbacks.onLocalVideoStream) { + callbacks.onLocalVideoStream(this.remoteUserId, null); + } + + // Send signaling message to notify remote peer + await sendSignalingMessage({ + type: "call_video_toggle", + fromUserId: 0, + toUserId: this.remoteUserId, + data: { enabled: false } + }); + + return false; + } + } + + /** + * Toggles screen sharing for this call + */ + async toggleScreenShare(): Promise { + if (!this.isScreenSharing) { + // Enable screen sharing + try { + const screenStream = await navigator.mediaDevices.getDisplayMedia({ + video: { + width: { ideal: 1920, max: 3840 }, + height: { ideal: 1080, max: 2160 }, + frameRate: { ideal: 60, max: 60 } + }, + audio: false + }); + + // Set a special ID to identify screen share streams + try { + Object.defineProperty(screenStream, "id", { + value: `screen-${crypto.randomUUID()}`, + writable: false, + configurable: true + }); + } catch (e) { + // Use default stream ID + } + + this.screenShareStream = screenStream; + this.isScreenSharing = true; + + // Add screen share track to peer connection + const videoTrack = screenStream.getVideoTracks()[0]; + + // Handle when user stops sharing via browser UI + videoTrack.addEventListener("ended", async () => { + + // Clean up screen share state + if (this.screenShareStream) { + this.screenShareStream.getTracks().forEach(t => t.stop()); + this.screenShareStream = null; + } + this.isScreenSharing = false; + + // Remove screen share track from peer connection + const senders = this.peerConnection.getSenders(); + const screenSender = senders.find(sender => + sender.track && sender.track.kind === 'video' && + sender.track.readyState === 'ended' && + this.transformedSenders.has(sender) + ); + + if (screenSender) { + await this.peerConnection.removeTrack(screenSender); + this.transformedSenders.delete(screenSender); + } + + // Notify local screen share handler + if (callbacks.onLocalScreenShare) { + callbacks.onLocalScreenShare(this.remoteUserId, null); + } + + // Notify state change handler + if (callbacks.onScreenShareStateChange) { + callbacks.onScreenShareStateChange(this.remoteUserId, false); + } + + // Send signaling message to remote peer + await sendSignalingMessage({ + type: "call_screen_share_toggle", + fromUserId: 0, // Will be set by server + toUserId: this.remoteUserId, + data: { enabled: false } + }); + }); + + // Send signaling message FIRST to notify remote peer before adding track + // This ensures the receiver knows it's screen share before the track arrives + await sendSignalingMessage({ + type: "call_screen_share_toggle", + fromUserId: 0, + toUserId: this.remoteUserId, + data: { enabled: true } + }); + + // Small delay to ensure signaling message is processed before track arrives + await delay(NEGOTIATION_DELAY); + + const sender = this.peerConnection.addTrack(videoTrack, screenStream); + this.screenShareSender = sender; + + // CRITICAL: Apply E2EE transform IMMEDIATELY after track is added + if (this.sessionKey && window.RTCRtpScriptTransform) { + try { + const key = await importAesGcmKey(this.sessionKey); + const sender = this.peerConnection.getSenders().find(s => s.track === videoTrack); + + if (sender && !this.transformedSenders.has(sender)) { + sender.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: "encrypt", sessionId: this.sessionId }); + this.transformedSenders.add(sender); + } + } catch (error) { + console.error("Failed to apply E2EE to screen share:", error); + throw error; // Fail securely + } + } + + // Notify local screen share handler + if (callbacks.onLocalScreenShare) { + callbacks.onLocalScreenShare(this.remoteUserId, screenStream); + } + + return true; + } catch (error) { + console.error("Failed to enable screen sharing:", error); + return false; + } + } else { + // Disable screen sharing + if (this.screenShareStream) { + this.screenShareStream.getTracks().forEach(track => { + track.stop(); + // Remove track from peer connection + const senders = this.peerConnection.getSenders(); + const screenSender = senders.find(s => s.track === track); + if (screenSender) { + this.peerConnection.removeTrack(screenSender); + this.transformedSenders.delete(screenSender); + // Clear sender reference + if (this.screenShareSender === screenSender) { + this.screenShareSender = null; + } + } + }); + this.screenShareStream = null; + } + + this.isScreenSharing = false; + + // Notify local screen share handler + if (callbacks.onLocalScreenShare) { + callbacks.onLocalScreenShare(this.remoteUserId, null); + } + + // Send signaling message to notify remote peer + await sendSignalingMessage({ + type: "call_screen_share_toggle", + fromUserId: 0, + toUserId: this.remoteUserId, + data: { enabled: false } + }); + + return false; + } + } + + + // --------- + // Lifecycle + // --------- + + /** + * Sets session key for this call + */ + async setSessionKey(keyBytes: Uint8Array): Promise { + this.sessionKey = keyBytes; + + await this.applyE2EETransforms(); + + // Start key rotation timer (rotate every 10 minutes for long calls) + if (this.keyRotationTimer) { + clearInterval(this.keyRotationTimer); + } + + this.keyRotationTimer = setInterval(async () => { + await this.rotateSessionKey(); + }, KEY_ROTATION_INTERVAL); + } + + /** + * Rotates session key for this call + */ + private async rotateSessionKey(): Promise { + if (!this.sessionKey) return; + + try { + // Generate new session key + const newSessionKey = await rotateCallSessionKey(); + + // Update the call with new session key + this.sessionKey = newSessionKey.key; + + // Reapply E2EE transforms with new key + await this.applyE2EETransforms(); + } catch (error) { + console.error("Failed to rotate session key:", error); + } + } + + /** + * Applies E2EE transforms to this call + */ + private async applyE2EETransforms(): Promise { + try { + if (!this.sessionKey || !window.RTCRtpScriptTransform) { + return; + } + + const key = await importAesGcmKey(this.sessionKey); + + // Apply to receivers that don't already have transforms + const receivers = this.peerConnection.getReceivers(); + for (const receiver of receivers) { + if (receiver.track && !this.transformedReceivers.has(receiver)) { + receiver.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'decrypt', sessionId: this.sessionId }); + this.transformedReceivers.add(receiver); + } + } + + // Apply to senders that don't already have transforms + const senders = this.peerConnection.getSenders(); + for (const sender of senders) { + if (sender.track && !this.transformedSenders.has(sender)) { + sender.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'encrypt', sessionId: this.sessionId }); + this.transformedSenders.add(sender); + } + } + } catch (error) { + console.error("Failed to apply E2EE transforms:", error); + } + } + + /** + * Creates E2EE transform for this call + */ + async createE2EETransform(sessionKey: Uint8Array, sessionId?: string): Promise { + try { + if (!sessionKey || !window.RTCRtpScriptTransform) { + return; + } + + const key = await importAesGcmKey(sessionKey); + + // Apply to receivers that don't already have transforms + const receivers = this.peerConnection.getReceivers(); + for (const receiver of receivers) { + if (receiver.track && !this.transformedReceivers.has(receiver)) { + receiver.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'decrypt', sessionId }); + this.transformedReceivers.add(receiver); + } + } + + // Apply to senders that don't already have transforms + const senders = this.peerConnection.getSenders(); + for (const sender of senders) { + if (sender.track && !this.transformedSenders.has(sender)) { + sender.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'encrypt', sessionId }); + this.transformedSenders.add(sender); + } + } + } catch (error) { + console.error("Failed to create E2EE transform:", error); + // Fail securely - throw to prevent call from continuing without E2EE + throw error; + } + } + + /** + * Sets remote video enabled state + */ + setRemoteVideoEnabled(enabled: boolean): void { + this.isRemoteVideoEnabled = enabled; + // Reset counter when feature is disabled + if (!enabled) { + this.receivedVideoTrackCount = 0; + } + } + + /** + * Sets remote screen sharing state + */ + setRemoteScreenSharing(enabled: boolean): void { + this.isRemoteScreenSharing = enabled; + // Reset counter when feature is disabled + if (!enabled) { + this.receivedScreenShareTrackCount = 0; + } + } + + /** + * Cleans up this call + */ + cleanup(): void { + // Clear key rotation timer + if (this.keyRotationTimer) { + clearInterval(this.keyRotationTimer); + } + + // Close peer connection + if (this.peerConnection) { + this.peerConnection.close(); + } + + // Stop local stream + if (this.localStream) { + this.localStream.getTracks().forEach(track => track.stop()); + } + + // Stop local video stream + if (this.localVideoStream) { + this.localVideoStream.getTracks().forEach(track => track.stop()); + } + + // Stop screen share stream + if (this.screenShareStream) { + this.screenShareStream.getTracks().forEach(track => track.stop()); + } + } } // Global state -export let authToken: string | null = null; -export let onCallStateChange: ((userId: number, state: string) => void) | null = null; -export let onRemoteStream: ((userId: number, stream: MediaStream) => void) | null = null; -export let onLocalVideoStream: ((userId: number, stream: MediaStream | null) => void) | null = null; -export let onRemoteVideoStream: ((userId: number, stream: MediaStream | null) => void) | null = null; -export let onLocalScreenShare: ((userId: number, stream: MediaStream | null) => void) | null = null; -export let onRemoteScreenShare: ((userId: number, stream: MediaStream | null) => void) | null = null; -export let onScreenShareStateChange: ((userId: number, isSharing: boolean) => void) | null = null; +export const callbacks = { + onCallStateChange: null as ((userId: number, state: string) => void) | null, + onRemoteStream: null as ((userId: number, stream: MediaStream) => void) | null, + onLocalVideoStream: null as ((userId: number, stream: MediaStream | null) => void) | null, + onRemoteVideoStream: null as ((userId: number, stream: MediaStream | null) => void) | null, + onLocalScreenShare: null as ((userId: number, stream: MediaStream | null) => void) | null, + onRemoteScreenShare: null as ((userId: number, stream: MediaStream | null) => void) | null, + onScreenShareStateChange: null as ((userId: number, isSharing: boolean) => void) | null, +} + const calls: Map = new Map(); -export function setAuthToken(token: string) { - authToken = token; -} - -export function setCallStateChangeHandler(handler: (userId: number, state: string) => void) { - onCallStateChange = handler; -} - -export function setRemoteStreamHandler(handler: (userId: number, stream: MediaStream) => void) { - onRemoteStream = handler; -} - -export function setLocalVideoStreamHandler(handler: (userId: number, stream: MediaStream | null) => void) { - onLocalVideoStream = handler; -} - -export function setRemoteVideoStreamHandler(handler: (userId: number, stream: MediaStream | null) => void) { - onRemoteVideoStream = handler; -} - -export function setLocalScreenShareHandler(handler: (userId: number, stream: MediaStream | null) => void) { - onLocalScreenShare = handler; -} - -export function setRemoteScreenShareHandler(handler: (userId: number, stream: MediaStream | null) => void) { - onRemoteScreenShare = handler; -} - /** * Sends a signaling message via WebSocket */ async function sendSignalingMessage(message: CallSignalingMessage) { - if (!authToken) { - throw new Error("No auth token available"); - } - await request({ type: "call_signaling", credentials: { scheme: "Bearer", - credentials: authToken + credentials: getAuthToken()! }, data: message }); } -/** - * Fetches ICE servers from the backend, with fallback to default STUN server - */ -async function getIceServers(): Promise { - if (!authToken) { - console.warn("No auth token available for ICE servers"); - return DEFAULT_ICE_SERVERS; - } - - try { - const response = await fetch("/api/webrtc/ice", { - headers: getAuthHeaders(authToken) - }); - - if (response.ok) { - const data = await response.json() as IceServersResponse; - return data.iceServers || []; - } else { - console.warn("Failed to fetch ICE servers:", response.status, response.statusText); - } - } catch (error) { - console.warn("Failed to fetch ICE servers:", error); - } - - // Fallback to STUN only if backend fails - return DEFAULT_ICE_SERVERS; -} - -async function createPeerConnection(userId: number): Promise { - const iceServers = await getIceServers(); - - const peerConnection = new RTCPeerConnection({ - iceServers - }); - - const call: WebRTCCall = { - peerConnection, - localStream: null, - remoteStream: null, - localVideoStream: null, - screenShareStream: null, - isInitiator: false, - remoteUserId: userId, - remoteUsername: "", - isMuted: false, - isLocalVideoEnabled: false, - isScreenSharing: false, - isRemoteScreenSharing: false, - isRemoteVideoEnabled: false, - sessionKey: null, - sessionCryptoKey: null, - sessionId: crypto.randomUUID(), - transformedSenders: new Set(), - transformedReceivers: new Set(), - receivedVideoTrackCount: 0, - receivedScreenShareTrackCount: 0 - }; +async function createPeerConnection(userId: number): Promise { + const call = new WebRTCCall(userId); + await call.initialize(); calls.set(userId, call); - - // Add ICE candidate event listener for sending - peerConnection.addEventListener("icecandidate", async (event) => { - if (event.candidate) { - // Send ICE candidate to remote peer - try { - await sendSignalingMessage({ - type: "call_ice_candidate", - fromUserId: 0, // Will be set by server - toUserId: userId, - data: { - candidate: event.candidate.candidate, - sdpMLineIndex: event.candidate.sdpMLineIndex, - sdpMid: event.candidate.sdpMid - } - }); - } catch (error) { - console.error("Failed to send ICE candidate:", error); - } - } - }); - - peerConnection.addEventListener("iceconnectionstatechange", () => { - // ICE connection state changed - }); - - peerConnection.addEventListener("signalingstatechange", () => { - // Signaling state changed - }); - - // Handle renegotiation when tracks are added/removed - peerConnection.addEventListener("negotiationneeded", async () => { - try { - const call = calls.get(userId); - if (!call) { - return; - } - - // Prevent multiple simultaneous negotiations - if (call.isNegotiating) { - return; - } - - // Skip if we're in "stable" state and haven't finished the initial handshake - if (peerConnection.signalingState !== "stable") { - return; - } - - call.isNegotiating = true; - - const offer = await peerConnection.createOffer(); - await peerConnection.setLocalDescription(offer); - - await sendSignalingMessage({ - type: "call_offer", - fromUserId: 0, // Will be set by server - toUserId: userId, - data: offer - }); - - call.isNegotiating = false; - } catch (error) { - console.error("Failed to handle negotiation:", error); - const call = calls.get(userId); - if (call) { - call.isNegotiating = false; - } - } - }); - - // Handle remote stream - peerConnection.addEventListener("track", async (event) => { - const [remoteStream] = event.streams; - const call = calls.get(userId); - if (call && remoteStream) { - const track = event.track; - - // Apply E2EE transform to all tracks - video now uses header-preserving encryption - if (call.sessionKey && window.RTCRtpScriptTransform) { - try { - const receiver = call.peerConnection.getReceivers().find(r => r.track === track); - if (receiver && !call.transformedReceivers.has(receiver)) { - const key = await importAesGcmKey(call.sessionKey); - receiver.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'decrypt', sessionId: call.sessionId }); - call.transformedReceivers.add(receiver); - } - } catch (error) { - console.error("Failed to apply E2EE to received track:", error); - } - } - - // Determine stream type based on track kind and signaling state - if (track.kind === "video") { - let isScreenShare = false; - let isVideo = false; - - if (call.isRemoteScreenSharing && call.isRemoteVideoEnabled) { - // Both active - route based on which one we haven't received yet - // Simple logic: if we haven't received video yet, this is video - // if we haven't received screen share yet, this is screen share - if (call.receivedVideoTrackCount === 0) { - isVideo = true; - call.receivedVideoTrackCount++; - } else if (call.receivedScreenShareTrackCount === 0) { - isScreenShare = true; - call.receivedScreenShareTrackCount++; - } else { - // Both already received - this might be a track replacement - isScreenShare = true; - } - } else if (call.isRemoteScreenSharing) { - isScreenShare = true; - call.receivedScreenShareTrackCount++; - } else if (call.isRemoteVideoEnabled) { - isVideo = true; - call.receivedVideoTrackCount++; - } - - if (isScreenShare) { - if (onRemoteScreenShare) { - onRemoteScreenShare(userId, remoteStream); - } - } else if (isVideo) { - if (onRemoteVideoStream) { - onRemoteVideoStream(userId, remoteStream); - } - } - } else if (track.kind === "audio") { - // Handle remote audio (existing behavior) - call.remoteStream = remoteStream; - if (onRemoteStream) { - onRemoteStream(userId, remoteStream); - } - } - } - }); - - // Handle connection state changes - peerConnection.addEventListener("connectionstatechange", () => { - // WebRTC connection state changed - const call = calls.get(userId); - if (call) { - if (onCallStateChange) { - onCallStateChange(userId, peerConnection.connectionState); - } - - // Clean up only on permanent failures - // Don't end on "disconnected" - ICE can recover from temporary disconnections - if (peerConnection.connectionState === "failed" || - peerConnection.connectionState === "closed") { - // Only send end call message if we're not already cleaning up - const call = calls.get(userId); - if (call && !call.isEnding) { - call.isEnding = true; - endCall(userId); - } - } - } - }); - - return peerConnection; + return call; } /** @@ -338,20 +808,18 @@ export async function initiateCall(userId: number, username: string): Promise call.peerConnection.addTrack(track, localStream)); // Enable insertable streams encryption on sender side if supported try { - if (call.peerConnection.getSenders && call.peerConnection.getSenders().length > 0 && window.RTCRtpScriptTransform) { + if (call.peerConnection.getSenders().length > 0 && window.RTCRtpScriptTransform) { const senders = call.peerConnection.getSenders(); for (const sender of senders) { if (!sender.track || sender.track.kind !== "audio") continue; @@ -393,9 +861,8 @@ export async function sendCallSessionKey(userId: number, sessionKeyHash: string) } export async function sendWrappedCallSessionKey(userId: number, sessionKey: Uint8Array, sessionKeyHash: string): Promise { - if (!authToken) throw new Error("No auth token available"); try { - const recipientPublicKey = await fetchUserPublicKey(userId, authToken); + const recipientPublicKey = await fetchUserPublicKey(userId, getAuthToken()!); if (!recipientPublicKey) { console.warn("No recipient public key for", userId); return; @@ -413,37 +880,6 @@ export async function sendWrappedCallSessionKey(userId: number, sessionKey: Uint } } -async function applyE2EETransforms(call: WebRTCCall): Promise { - try { - if (!call.sessionKey || !window.RTCRtpScriptTransform) { - return; - } - - const key = await importAesGcmKey(call.sessionKey); - call.sessionCryptoKey = key; - - // Apply to receivers that don't already have transforms - const receivers = call.peerConnection.getReceivers(); - for (const receiver of receivers) { - if (receiver.track && !call.transformedReceivers.has(receiver)) { - receiver.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'decrypt', sessionId: call.sessionId }); - call.transformedReceivers.add(receiver); - } - } - - // Apply to senders that don't already have transforms - const senders = call.peerConnection.getSenders(); - for (const sender of senders) { - if (sender.track && !call.transformedSenders.has(sender)) { - sender.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'encrypt', sessionId: call.sessionId }); - call.transformedSenders.add(sender); - } - } - } catch (error) { - console.error("Failed to apply E2EE transforms:", error); - } -} - export async function setSessionKey(userId: number, keyBytes: Uint8Array): Promise { const call = calls.get(userId); if (!call) { @@ -451,47 +887,17 @@ export async function setSessionKey(userId: number, keyBytes: Uint8Array): Promi return; } - call.sessionKey = keyBytes; - call.lastKeyRotation = Date.now(); - - await applyE2EETransforms(call); - - // Start key rotation timer (rotate every 10 minutes for long calls) - if (call.keyRotationTimer) { - clearInterval(call.keyRotationTimer); - } - - call.keyRotationTimer = setInterval(async () => { - await rotateSessionKey(userId); - }, KEY_ROTATION_INTERVAL); + await call.setSessionKey(keyBytes); } -/** - * Rotate the session key for a call to provide forward secrecy - */ -async function rotateSessionKey(userId: number): Promise { - const call = calls.get(userId); - if (!call || !call.sessionKey) return; - - try { - // Generate new session key - const newSessionKey = await rotateCallSessionKey(); - - // Update the call with new session key - call.sessionKey = newSessionKey.key; - call.lastKeyRotation = Date.now(); - - // Reapply E2EE transforms with new key - await applyE2EETransforms(call); - } catch (error) { - console.error("Failed to rotate session key:", error); - } -} -export async function receiveWrappedSessionKey(fromUserId: number, wrappedPayload: WrappedSessionKeyPayload, sessionKeyHash?: string): Promise { - if (!authToken) return; +export async function receiveWrappedSessionKey( + fromUserId: number, + wrappedPayload: WrappedSessionKeyPayload, + sessionKeyHash?: string +): Promise { try { - const senderPublicKey = await fetchUserPublicKey(fromUserId, authToken); + const senderPublicKey = await fetchUserPublicKey(fromUserId, getAuthToken()!); if (!senderPublicKey) { console.error("Failed to get sender public key"); return; @@ -525,8 +931,7 @@ export async function acceptCall(userId: number): Promise { let call = calls.get(userId); if (!call) { // Create call object if it doesn't exist (for race conditions) - await createPeerConnection(userId); - call = calls.get(userId); + call = await createPeerConnection(userId); if (!call) return false; } @@ -584,12 +989,10 @@ export async function endCall(userId: number): Promise { export async function handleIncomingCall(userId: number, username: string): Promise { try { // Create peer connection for incoming call - await createPeerConnection(userId); - const call = calls.get(userId); + const call = await createPeerConnection(userId); if (!call) return; call.remoteUsername = username; - call.isInitiator = false; } catch (error) { console.error("Failed to handle incoming call:", error); cleanupCall(userId); @@ -624,45 +1027,12 @@ export async function onRemoteAccepted(userId: number): Promise { } } -async function createE2EETransform(call: WebRTCCall, sessionKey: Uint8Array, sessionId?: string): Promise { - try { - if (!sessionKey || !window.RTCRtpScriptTransform) { - return; - } - - const key = await importAesGcmKey(sessionKey); - - // Apply to receivers that don't already have transforms - const receivers = call.peerConnection.getReceivers(); - for (const receiver of receivers) { - if (receiver.track && !call.transformedReceivers.has(receiver)) { - receiver.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'decrypt', sessionId }); - call.transformedReceivers.add(receiver); - } - } - - // Apply to senders that don't already have transforms - const senders = call.peerConnection.getSenders(); - for (const sender of senders) { - if (sender.track && !call.transformedSenders.has(sender)) { - sender.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'encrypt', sessionId }); - call.transformedSenders.add(sender); - } - } - } catch (error) { - console.error("Failed to create E2EE transform:", error); - // Fail securely - throw to prevent call from continuing without E2EE - throw error; - } -} - export async function handleCallOffer(userId: number, offer: RTCSessionDescriptionInit): Promise { let call = calls.get(userId); // Handle race condition - offer might arrive before peer connection is created if (!call) { - await createPeerConnection(userId); - call = calls.get(userId); + call = await createPeerConnection(userId); if (!call) { throw new Error("Failed to create call for offer"); } @@ -691,7 +1061,7 @@ export async function handleCallOffer(userId: number, offer: RTCSessionDescripti // Attach transforms on callee side if session key is available // If not available yet, setSessionKey will apply them when it arrives if (call.sessionKey) { - await createE2EETransform(call, call.sessionKey, call.sessionId); + await call.createE2EETransform(call.sessionKey, call.sessionId); } // Send answer to remote peer @@ -722,7 +1092,7 @@ export async function handleCallAnswer(userId: number, answer: RTCSessionDescrip // Attach transforms on initiator side if session key is available // If not available yet, setSessionKey will apply them when it arrives if (call.sessionKey) { - await createE2EETransform(call, call.sessionKey, call.sessionId); + await call.createE2EETransform(call.sessionKey, call.sessionId); } // Check if there are new receivers with tracks that haven't been notified yet @@ -736,10 +1106,10 @@ export async function handleCallAnswer(userId: number, answer: RTCSessionDescrip const transceiver = call.peerConnection.getTransceivers().find(t => t.receiver === receiver); if (transceiver && transceiver.receiver.track) { // Manually trigger stream handlers for tracks that didn't fire events - if (track.kind === "video" && onRemoteVideoStream) { + if (track.kind === "video" && callbacks.onRemoteVideoStream) { // Create a MediaStream from the track const stream = new MediaStream([track]); - onRemoteVideoStream(userId, stream); + callbacks.onRemoteVideoStream(userId, stream); } } } @@ -766,78 +1136,10 @@ export async function handleIceCandidate(userId: number, candidate: RTCIceCandid export function toggleMute(userId: number): boolean { const call = calls.get(userId); - if (!call || !call.localStream) { + if (!call) { return false; } - - if (!call.isMuted) { - // Mute: Stop the track completely (no green dot) - const audioTrack = call.localStream.getAudioTracks()[0]; - if (audioTrack) { - audioTrack.stop(); - call.localStream.removeTrack(audioTrack); - } - - // Create a silent audio track using Web Audio API - const AudioContextClass = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; - const audioContext = new AudioContextClass(); - const oscillator = audioContext.createOscillator(); - const gainNode = audioContext.createGain(); - - // Set gain to 0 (silent) - gainNode.gain.setValueAtTime(0, audioContext.currentTime); - - // Connect nodes - oscillator.connect(gainNode); - - // Create a MediaStreamDestination to get a MediaStream - const destination = audioContext.createMediaStreamDestination(); - gainNode.connect(destination); - - // Start the oscillator (but it's silent due to gain = 0) - oscillator.start(); - - // Add the silent track to maintain WebRTC connection - const silentTrack = destination.stream.getAudioTracks()[0]; - if (silentTrack) { - call.localStream.addTrack(silentTrack); - } - - call.isMuted = true; - return true; // Muted - } else { - // Unmute: Re-enable microphone by getting new audio stream - navigator.mediaDevices.getUserMedia({ audio: true, video: false }) - .then(newStream => { - // Remove any existing audio tracks from the stream - call.localStream!.getAudioTracks().forEach(track => track.stop()); - - // Get the new active track - const newAudioTrack = newStream.getAudioTracks()[0]; - - // Replace the track in the peer connection - const sender = call.peerConnection.getSenders().find(s => - s.track && s.track.kind === 'audio' - ); - - if (sender) { - // Replace the track in the existing sender - sender.replaceTrack(newAudioTrack); - } else { - // Add the track to the peer connection if no sender exists - call.peerConnection.addTrack(newAudioTrack, call.localStream!); - } - - // Add the track to the local stream - call.localStream!.addTrack(newAudioTrack); - - call.isMuted = false; - }) - .catch(error => { - console.error("Failed to re-enable microphone:", error); - }); - return false; // Unmuted - } + return call.toggleMute(); } export function getCall(userId: number): WebRTCCall | undefined { @@ -849,93 +1151,7 @@ export async function toggleVideo(userId: number): Promise { if (!call) { return false; } - - if (!call.isLocalVideoEnabled) { - // Enable video - try { - const videoStream = await navigator.mediaDevices.getUserMedia({ - video: true, - audio: false - }); - - call.localVideoStream = videoStream; - call.isLocalVideoEnabled = true; - - // Add video track to peer connection - const videoTrack = videoStream.getVideoTracks()[0]; - const sender = call.peerConnection.addTrack(videoTrack, videoStream); - call.videoSender = sender; - - // Apply E2EE transform with header-preserving encryption for video - if (call.sessionKey && window.RTCRtpScriptTransform) { - try { - const key = await importAesGcmKey(call.sessionKey); - const sender = call.peerConnection.getSenders().find(s => s.track === videoTrack); - if (sender && !call.transformedSenders.has(sender)) { - sender.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: "encrypt", sessionId: call.sessionId }); - call.transformedSenders.add(sender); - } - } catch (error) { - console.error("Failed to apply E2EE to video:", error); - throw error; // Fail securely - } - } - - // Notify local video stream handler - if (onLocalVideoStream) { - onLocalVideoStream(userId, videoStream); - } - - // Send signaling message to notify remote peer - await sendSignalingMessage({ - type: "call_video_toggle", - fromUserId: 0, - toUserId: userId, - data: { enabled: true } - }); - - return true; - } catch (error) { - console.error("Failed to enable video:", error); - return false; - } - } else { - // Disable video - if (call.localVideoStream) { - call.localVideoStream.getTracks().forEach(track => { - track.stop(); - // Remove track from peer connection - const senders = call.peerConnection.getSenders(); - const videoSender = senders.find(s => s.track === track); - if (videoSender) { - call.peerConnection.removeTrack(videoSender); - call.transformedSenders.delete(videoSender); - // Clear sender reference - if (call.videoSender === videoSender) { - call.videoSender = null; - } - } - }); - call.localVideoStream = null; - } - - call.isLocalVideoEnabled = false; - - // Notify local video stream handler - if (onLocalVideoStream) { - onLocalVideoStream(userId, null); - } - - // Send signaling message to notify remote peer - await sendSignalingMessage({ - type: "call_video_toggle", - fromUserId: 0, - toUserId: userId, - data: { enabled: false } - }); - - return false; - } + return await call.toggleVideo(); } export async function toggleScreenShare(userId: number): Promise { @@ -943,186 +1159,13 @@ export async function toggleScreenShare(userId: number): Promise { if (!call) { return false; } - - if (!call.isScreenSharing) { - // Enable screen sharing - try { - const screenStream = await navigator.mediaDevices.getDisplayMedia({ - video: { - width: { ideal: 1920, max: 3840 }, - height: { ideal: 1080, max: 2160 }, - frameRate: { ideal: 60, max: 60 } - }, - audio: false - }); - - // Set a special ID to identify screen share streams - try { - Object.defineProperty(screenStream, "id", { - value: `screen-${crypto.randomUUID()}`, - writable: false, - configurable: true - }); - } catch (e) { - // Use default stream ID - } - - call.screenShareStream = screenStream; - call.isScreenSharing = true; - - // Add screen share track to peer connection - const videoTrack = screenStream.getVideoTracks()[0]; - - // Handle when user stops sharing via browser UI - videoTrack.addEventListener("ended", async () => { - - // Clean up screen share state - if (call.screenShareStream) { - call.screenShareStream.getTracks().forEach(t => t.stop()); - call.screenShareStream = null; - } - call.isScreenSharing = false; - - // Remove screen share track from peer connection - const senders = call.peerConnection.getSenders(); - const screenSender = senders.find(sender => - sender.track && sender.track.kind === 'video' && - sender.track.readyState === 'ended' && - call.transformedSenders.has(sender) - ); - - if (screenSender) { - await call.peerConnection.removeTrack(screenSender); - call.transformedSenders.delete(screenSender); - } - - // Notify local screen share handler - if (onLocalScreenShare) { - onLocalScreenShare(userId, null); - } - - // Notify state change handler - if (onScreenShareStateChange) { - onScreenShareStateChange(userId, false); - } - - // Send signaling message to remote peer - await sendSignalingMessage({ - type: "call_screen_share_toggle", - fromUserId: 0, // Will be set by server - toUserId: userId, - data: { enabled: false } - }); - }); - - // Send signaling message FIRST to notify remote peer before adding track - // This ensures the receiver knows it's screen share before the track arrives - await sendSignalingMessage({ - type: "call_screen_share_toggle", - fromUserId: 0, - toUserId: userId, - data: { enabled: true } - }); - - // Small delay to ensure signaling message is processed before track arrives - await delay(NEGOTIATION_DELAY); - - const sender = call.peerConnection.addTrack(videoTrack, screenStream); - call.screenShareSender = sender; - - // CRITICAL: Apply E2EE transform IMMEDIATELY after track is added - if (call.sessionKey && window.RTCRtpScriptTransform) { - try { - const key = await importAesGcmKey(call.sessionKey); - const sender = call.peerConnection.getSenders().find(s => s.track === videoTrack); - - if (sender && !call.transformedSenders.has(sender)) { - sender.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: "encrypt", sessionId: call.sessionId }); - call.transformedSenders.add(sender); - } - } catch (error) { - console.error("Failed to apply E2EE to screen share:", error); - throw error; // Fail securely - } - } - - // Notify local screen share handler - if (onLocalScreenShare) { - onLocalScreenShare(userId, screenStream); - } - - return true; - } catch (error) { - console.error("Failed to enable screen sharing:", error); - return false; - } - } else { - // Disable screen sharing - if (call.screenShareStream) { - call.screenShareStream.getTracks().forEach(track => { - track.stop(); - // Remove track from peer connection - const senders = call.peerConnection.getSenders(); - const screenSender = senders.find(s => s.track === track); - if (screenSender) { - call.peerConnection.removeTrack(screenSender); - call.transformedSenders.delete(screenSender); - // Clear sender reference - if (call.screenShareSender === screenSender) { - call.screenShareSender = null; - } - } - }); - call.screenShareStream = null; - } - - call.isScreenSharing = false; - - // Notify local screen share handler - if (onLocalScreenShare) { - onLocalScreenShare(userId, null); - } - - // Send signaling message to notify remote peer - await sendSignalingMessage({ - type: "call_screen_share_toggle", - fromUserId: 0, - toUserId: userId, - data: { enabled: false } - }); - - return false; - } + return await call.toggleScreenShare(); } export function cleanupCall(userId: number): void { const call = calls.get(userId); if (call) { - // Clear key rotation timer - if (call.keyRotationTimer) { - clearInterval(call.keyRotationTimer); - } - - // Close peer connection - if (call.peerConnection) { - call.peerConnection.close(); - } - - // Stop local stream - if (call.localStream) { - call.localStream.getTracks().forEach(track => track.stop()); - } - - // Stop local video stream - if (call.localVideoStream) { - call.localVideoStream.getTracks().forEach(track => track.stop()); - } - - // Stop screen share stream - if (call.screenShareStream) { - call.screenShareStream.getTracks().forEach(track => track.stop()); - } - + call.cleanup(); calls.delete(userId); } } @@ -1133,11 +1176,7 @@ export function cleanupCall(userId: number): void { export function setRemoteVideoEnabled(userId: number, enabled: boolean): void { const call = calls.get(userId); if (call) { - call.isRemoteVideoEnabled = enabled; - // Reset counter when feature is disabled - if (!enabled) { - call.receivedVideoTrackCount = 0; - } + call.setRemoteVideoEnabled(enabled); } } @@ -1147,18 +1186,10 @@ export function setRemoteVideoEnabled(userId: number, enabled: boolean): void { export function setRemoteScreenSharing(userId: number, enabled: boolean): void { const call = calls.get(userId); if (call) { - call.isRemoteScreenSharing = enabled; - // Reset counter when feature is disabled - if (!enabled) { - call.receivedScreenShareTrackCount = 0; - } + call.setRemoteScreenSharing(enabled); } } -export function setScreenShareStateChangeHandler(handler: ((userId: number, isSharing: boolean) => void) | null): void { - onScreenShareStateChange = handler; -} - export function cleanup(): void { // Clean up all calls for (const userId of calls.keys()) { diff --git a/frontend/src/pages/chat/hooks/useCall.ts b/frontend/src/pages/chat/hooks/useCall.ts index f945fcf..bf5e611 100644 --- a/frontend/src/pages/chat/hooks/useCall.ts +++ b/frontend/src/pages/chat/hooks/useCall.ts @@ -18,11 +18,11 @@ export default function useCall() { chat, startCall, endCall, - setCallStatus, + setCallStatus, toggleMute, toggleVideo, toggleScreenShare, - setCallEncryption, + setCallEncryption, setCallSessionKeyHash, setRemoteVideoEnabled, setRemoteScreenSharing, @@ -36,10 +36,6 @@ export default function useCall() { const remoteScreenShareRef = globalRemoteScreenShareRef; useEffect(() => { - if (user.authToken) { - WebRTC.setAuthToken(user.authToken); - } - // Initialize call signaling handler const signalingHandler = new CallSignalingHandler(() => ({ receiveCall: (userId: number, username: string) => { @@ -55,7 +51,7 @@ export default function useCall() { setCallSignalingHandler(signalingHandler); // Set up call state change handler - WebRTC.setCallStateChangeHandler((userId: number, state: string) => { + WebRTC.callbacks.onCallStateChange = (userId: number, state: string) => { const call = chat.call; if (call.remoteUserId === userId) { switch (state) { @@ -72,10 +68,10 @@ export default function useCall() { break; } } - }); + }; // Set up remote audio stream handler - WebRTC.setRemoteStreamHandler((_userId: number, stream: MediaStream) => { + WebRTC.callbacks.onRemoteStream = (_userId: number, stream: MediaStream) => { if (!remoteAudioRef.current) { return; } @@ -97,10 +93,10 @@ export default function useCall() { } catch (e) { console.warn("failed to attach remote stream:", e); } - }); + }; // Set up local video stream handler - WebRTC.setLocalVideoStreamHandler((_userId: number, stream: MediaStream | null) => { + WebRTC.callbacks.onLocalVideoStream = (_userId: number, stream: MediaStream | null) => { if (!localVideoRef.current) { return; } @@ -118,10 +114,10 @@ export default function useCall() { } catch (e) { console.warn("failed to attach local video stream:", e); } - }); + }; // Set up remote video stream handler - WebRTC.setRemoteVideoStreamHandler((_userId: number, stream: MediaStream | null) => { + WebRTC.callbacks.onRemoteVideoStream = (_userId: number, stream: MediaStream | null) => { if (!remoteVideoRef.current) { return; } @@ -139,10 +135,10 @@ export default function useCall() { } catch (e) { console.warn("failed to attach remote video stream:", e); } - }); + }; // Set up local screen share handler - WebRTC.setLocalScreenShareHandler((_userId: number, stream: MediaStream | null) => { + WebRTC.callbacks.onLocalScreenShare = (_userId: number, stream: MediaStream | null) => { if (!localScreenShareRef.current) { return; } @@ -160,10 +156,10 @@ export default function useCall() { } catch (e) { console.warn("failed to attach local screen share stream:", e); } - }); + }; // Set up remote screen share handler - WebRTC.setRemoteScreenShareHandler((_userId: number, stream: MediaStream | null) => { + WebRTC.callbacks.onRemoteScreenShare = (_userId: number, stream: MediaStream | null) => { if (!remoteScreenShareRef.current) { return; } @@ -181,7 +177,7 @@ export default function useCall() { } catch (e) { console.warn("failed to attach remote screen share stream:", e); } - }); + }; return () => { WebRTC.cleanup();