/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion, @typescript-eslint/member-ordering, id-length, id-denylist, max-statements-per-line, max-len */ /** * Manages screen sharing: getDisplayMedia / Electron desktop capturer, * mixed audio (screen + mic), and attaching screen tracks to peers. */ import { WebRTCLogger } from './webrtc-logger'; import { PeerData } from './webrtc.types'; import { TRACK_KIND_AUDIO, TRACK_KIND_VIDEO, TRANSCEIVER_SEND_RECV, TRANSCEIVER_RECV_ONLY, SCREEN_SHARE_IDEAL_WIDTH, SCREEN_SHARE_IDEAL_HEIGHT, SCREEN_SHARE_IDEAL_FRAME_RATE, ELECTRON_ENTIRE_SCREEN_SOURCE_NAME } from './webrtc.constants'; /** * Callbacks the ScreenShareManager needs from the owning service. */ export interface ScreenShareCallbacks { getActivePeers(): Map; getLocalMediaStream(): MediaStream | null; renegotiate(peerId: string): Promise; broadcastCurrentStates(): void; } export class ScreenShareManager { /** The active screen-capture stream. */ private activeScreenStream: MediaStream | null = null; /** Mixed audio stream (screen audio + mic). */ private combinedAudioStream: MediaStream | null = null; /** AudioContext used to mix screen + mic audio. */ private audioMixingContext: AudioContext | null = null; /** Whether screen sharing is currently active. */ private isScreenActive = false; constructor( private readonly logger: WebRTCLogger, private callbacks: ScreenShareCallbacks ) {} /** * Replace the callback set at runtime. * Needed because of circular initialisation between managers. * * @param cb - The new callback interface to wire into this manager. */ setCallbacks(cb: ScreenShareCallbacks): void { this.callbacks = cb; } /** Returns the current screen-capture stream, or `null` if inactive. */ getScreenStream(): MediaStream | null { return this.activeScreenStream; } /** Whether screen sharing is currently active. */ getIsScreenActive(): boolean { return this.isScreenActive; } /** * Begin screen sharing. * * Tries the Electron desktop capturer API first (for Electron builds), * then falls back to standard `getDisplayMedia`. * Optionally includes system audio mixed with the microphone. * * @param includeSystemAudio - Whether to capture system / tab audio alongside the video. * @returns The captured screen {@link MediaStream}. * @throws If both Electron and browser screen capture fail. */ async startScreenShare(includeSystemAudio = false): Promise { try { this.logger.info('startScreenShare invoked', { includeSystemAudio }); // Try Electron desktop capturer first if (typeof window !== 'undefined' && (window as any).electronAPI?.getSources) { try { const sources = await (window as any).electronAPI.getSources(); const screenSource = sources.find((s: any) => s.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME) || sources[0]; const electronConstraints: any = { video: { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: screenSource.id } } }; if (includeSystemAudio) { electronConstraints.audio = { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: screenSource.id } }; } else { electronConstraints.audio = false; } this.logger.info('desktopCapturer constraints', electronConstraints); if (!navigator.mediaDevices?.getUserMedia) { throw new Error('navigator.mediaDevices is not available (requires HTTPS or localhost).'); } this.activeScreenStream = await navigator.mediaDevices.getUserMedia(electronConstraints); } catch (e) { this.logger.warn('Electron desktop capture failed; falling back to getDisplayMedia', e as any); } } // Fallback to standard getDisplayMedia if (!this.activeScreenStream) { const displayConstraints: DisplayMediaStreamOptions = { video: { width: { ideal: SCREEN_SHARE_IDEAL_WIDTH }, height: { ideal: SCREEN_SHARE_IDEAL_HEIGHT }, frameRate: { ideal: SCREEN_SHARE_IDEAL_FRAME_RATE } }, audio: includeSystemAudio ? { echoCancellation: false, noiseSuppression: false, autoGainControl: false } : false } as any; this.logger.info('getDisplayMedia constraints', displayConstraints); if (!navigator.mediaDevices) { throw new Error('navigator.mediaDevices is not available (requires HTTPS or localhost).'); } this.activeScreenStream = await (navigator.mediaDevices as any).getDisplayMedia(displayConstraints); } this.logger.logStream('screen', this.activeScreenStream); // Prepare mixed audio if system audio is included this.prepareMixedAudio(includeSystemAudio); // Attach tracks to peers this.attachScreenTracksToPeers(includeSystemAudio); this.isScreenActive = true; // Auto-stop when user ends share via browser UI const screenVideoTrack = this.activeScreenStream!.getVideoTracks()[0]; if (screenVideoTrack) { screenVideoTrack.onended = () => { this.logger.warn('Screen video track ended'); this.stopScreenShare(); }; } return this.activeScreenStream!; } catch (error) { this.logger.error('Failed to start screen share', error); throw error; } } /** * Stop screen sharing and restore the microphone audio track on all peers. * * Stops all screen-capture tracks, tears down mixed audio, * resets video transceivers to receive-only, and triggers renegotiation. */ stopScreenShare(): void { if (this.activeScreenStream) { this.activeScreenStream.getTracks().forEach((track) => track.stop()); this.activeScreenStream = null; this.isScreenActive = false; this.callbacks.broadcastCurrentStates(); } // Clean up mixed audio if (this.combinedAudioStream) { try { this.combinedAudioStream.getTracks().forEach((track) => track.stop()); } catch (error) { this.logger.warn('Failed to stop combined screen-share audio tracks', error as any); } this.combinedAudioStream = null; } // Remove video track and restore mic on all peers this.callbacks.getActivePeers().forEach((peerData, peerId) => { const transceivers = peerData.connection.getTransceivers(); const videoTransceiver = transceivers.find(transceiver => transceiver.sender === peerData.videoSender || transceiver.sender === peerData.screenVideoSender); if (videoTransceiver) { videoTransceiver.sender.replaceTrack(null).catch((error) => { this.logger.error('Failed to clear screen video sender track', error, { peerId }); }); if (videoTransceiver.direction === TRANSCEIVER_SEND_RECV) { videoTransceiver.direction = TRANSCEIVER_RECV_ONLY; } } peerData.screenVideoSender = undefined; peerData.screenAudioSender = undefined; // Restore mic track const micTrack = this.callbacks.getLocalMediaStream()?.getAudioTracks()[0] || null; if (micTrack) { let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_AUDIO); if (!audioSender) { const transceiver = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV }); audioSender = transceiver.sender; } peerData.audioSender = audioSender; audioSender.replaceTrack(micTrack).catch((error) => this.logger.error('Restore mic replaceTrack failed', error)); } this.callbacks.renegotiate(peerId); }); } /** * Create a mixed audio stream from screen audio + microphone audio * using the Web Audio API ({@link AudioContext}). * * Falls back to screen-audio-only if mixing fails. * * @param includeSystemAudio - Whether system audio should be mixed in. */ private prepareMixedAudio(includeSystemAudio: boolean): void { const screenAudioTrack = includeSystemAudio ? (this.activeScreenStream?.getAudioTracks()[0] || null) : null; const micAudioTrack = this.callbacks.getLocalMediaStream()?.getAudioTracks()[0] || null; if (includeSystemAudio && screenAudioTrack) { try { if (!this.audioMixingContext && (window as any).AudioContext) { this.audioMixingContext = new (window as any).AudioContext(); } if (!this.audioMixingContext) throw new Error('AudioContext not available'); const destination = this.audioMixingContext.createMediaStreamDestination(); const screenAudioSource = this.audioMixingContext.createMediaStreamSource(new MediaStream([screenAudioTrack])); screenAudioSource.connect(destination); if (micAudioTrack) { const micAudioSource = this.audioMixingContext.createMediaStreamSource(new MediaStream([micAudioTrack])); micAudioSource.connect(destination); this.logger.info('Mixed mic + screen audio together'); } this.combinedAudioStream = destination.stream; this.logger.logStream('combinedAudio(screen+mic)', this.combinedAudioStream); } catch (e) { this.logger.warn('Mixed audio creation failed; fallback to screen audio only', e as any); this.combinedAudioStream = screenAudioTrack ? new MediaStream([screenAudioTrack]) : null; this.logger.logStream('combinedAudio(fallback)', this.combinedAudioStream); } } else { this.combinedAudioStream = null; } } /** * Attach screen video and (optionally) combined audio tracks to all * active peer connections, then trigger SDP renegotiation. * * @param includeSystemAudio - Whether the combined audio track should replace the mic sender. */ private attachScreenTracksToPeers(includeSystemAudio: boolean): void { this.callbacks.getActivePeers().forEach((peerData, peerId) => { if (!this.activeScreenStream) return; const screenVideoTrack = this.activeScreenStream.getVideoTracks()[0]; if (!screenVideoTrack) return; this.logger.attachTrackDiagnostics(screenVideoTrack, `screenVideo:${peerId}`); // Use primary video sender/transceiver let videoSender = peerData.videoSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_VIDEO); if (!videoSender) { const videoTransceiver = peerData.connection.addTransceiver(TRACK_KIND_VIDEO, { direction: TRANSCEIVER_SEND_RECV }); videoSender = videoTransceiver.sender; peerData.videoSender = videoSender; } else { const transceivers = peerData.connection.getTransceivers(); const videoTransceiver = transceivers.find(t => t.sender === videoSender); if (videoTransceiver?.direction === TRANSCEIVER_RECV_ONLY) { videoTransceiver.direction = TRANSCEIVER_SEND_RECV; } } peerData.screenVideoSender = videoSender; videoSender.replaceTrack(screenVideoTrack) .then(() => this.logger.info('screen video replaceTrack ok', { peerId })) .catch((e) => this.logger.error('screen video replaceTrack failed', e)); // Audio handling const micTrack = this.callbacks.getLocalMediaStream()?.getAudioTracks()[0] || null; if (includeSystemAudio) { const combinedTrack = this.combinedAudioStream?.getAudioTracks()[0] || null; if (combinedTrack) { this.logger.attachTrackDiagnostics(combinedTrack, `combinedAudio:${peerId}`); let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_AUDIO); if (!audioSender) { const transceiver = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV }); audioSender = transceiver.sender; } peerData.audioSender = audioSender; audioSender.replaceTrack(combinedTrack) .then(() => this.logger.info('screen audio(combined) replaceTrack ok', { peerId })) .catch((e) => this.logger.error('audio replaceTrack (combined) failed', e)); } } else if (micTrack) { this.logger.attachTrackDiagnostics(micTrack, `micAudio:${peerId}`); let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_AUDIO); if (!audioSender) { const transceiver = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV }); audioSender = transceiver.sender; } peerData.audioSender = audioSender; audioSender.replaceTrack(micTrack) .then(() => this.logger.info('screen audio(mic) replaceTrack ok', { peerId })) .catch((e) => this.logger.error('audio replaceTrack (mic) failed', e)); } this.callbacks.renegotiate(peerId); }); } /** Clean up all resources. */ destroy(): void { this.stopScreenShare(); if (this.audioMixingContext) { try { this.audioMixingContext.close(); } catch (error) { this.logger.warn('Failed to close audio mixing context during destroy', error as any); } this.audioMixingContext = null; } } }