feat: add call management features with useCall and useRoomCallMembers hooks
- Implemented useCall hook for managing call state and actions. - Created useRoomCallMembers hook to track active call members in a room. - Added useRtcConfig for fetching RTC configurations and LiveKit JWT. - Developed Audio settings component for managing audio devices and settings. - Introduced device selection and screen sharing options in the Audio settings. - Persisted audio settings in localStorage for user preferences.
This commit is contained in:
737
src/app/features/call/CallService.ts
Normal file
737
src/app/features/call/CallService.ts
Normal file
@@ -0,0 +1,737 @@
|
||||
/* eslint-disable no-console */
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import {
|
||||
Room,
|
||||
RoomEvent,
|
||||
RemoteParticipant,
|
||||
RemoteTrackPublication,
|
||||
ConnectionState,
|
||||
ParticipantEvent,
|
||||
Participant,
|
||||
LocalTrackPublication,
|
||||
Track,
|
||||
} from 'livekit-client';
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import {
|
||||
ActiveCall,
|
||||
CallEvent,
|
||||
CallEventType,
|
||||
CallServiceConfig,
|
||||
CallState,
|
||||
CallType,
|
||||
LiveKitJWTResponse,
|
||||
CallMemberEventContent,
|
||||
} from './types';
|
||||
import { fetchLiveKitJWT, fetchWellKnownWithRTC, getLiveKitFocus, requestOpenIdToken } from './useRtcConfig';
|
||||
import { getAudioSettings, SCREEN_SHARE_RESOLUTIONS, SCREEN_SHARE_BITRATES } from '../settings/audio/Audio';
|
||||
|
||||
type CallEventListener = (event: CallEvent) => void;
|
||||
|
||||
/** The Matrix state event type for call membership (MSC3401) */
|
||||
const CALL_MEMBER_EVENT_TYPE = 'org.matrix.msc3401.call.member';
|
||||
|
||||
/**
|
||||
* Service for managing Matrix RTC calls via LiveKit
|
||||
* Handles LiveKit connections, media streams, call state, and Matrix room signaling
|
||||
*/
|
||||
export class CallService {
|
||||
private config: CallServiceConfig;
|
||||
|
||||
private matrixClient: MatrixClient;
|
||||
|
||||
private activeCall: ActiveCall | null = null;
|
||||
|
||||
private listeners: Set<CallEventListener> = new Set();
|
||||
|
||||
private livekitRoom: Room | null = null;
|
||||
|
||||
private livekitJwt: LiveKitJWTResponse | null = null;
|
||||
|
||||
/**
|
||||
* Creates a new CallService instance
|
||||
* @param config - Service configuration including homeserver and LiveKit URLs
|
||||
* @param matrixClient - The Matrix client for sending state events
|
||||
*/
|
||||
constructor(config: CallServiceConfig, matrixClient: MatrixClient) {
|
||||
this.config = config;
|
||||
this.matrixClient = matrixClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to call events
|
||||
* @param listener - Callback function for call events
|
||||
* @returns Unsubscribe function
|
||||
*/
|
||||
on(listener: CallEventListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a call event to all listeners
|
||||
*/
|
||||
private emit(event: CallEvent): void {
|
||||
this.listeners.forEach((listener) => listener(event));
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates call state and emits event
|
||||
*/
|
||||
private setState(state: CallState): void {
|
||||
if (this.activeCall) {
|
||||
this.activeCall.state = state;
|
||||
this.emit({
|
||||
type: CallEventType.StateChanged,
|
||||
roomId: this.activeCall.roomId,
|
||||
data: { state },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current active call
|
||||
*/
|
||||
getActiveCall(): ActiveCall | null {
|
||||
return this.activeCall;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if calls are supported on this homeserver
|
||||
*/
|
||||
async isCallSupported(): Promise<boolean> {
|
||||
try {
|
||||
const wellKnown = await fetchWellKnownWithRTC(this.config.homeserverBaseUrl);
|
||||
return getLiveKitFocus(wellKnown) !== undefined;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique call ID for this session
|
||||
*/
|
||||
private generateCallId(): string {
|
||||
return `${this.config.deviceId}_${Date.now()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a call member state event to announce participation in a call
|
||||
* @param roomId - The Matrix room ID
|
||||
* @param callId - The unique call ID
|
||||
* @param active - Whether the user is actively in the call
|
||||
*/
|
||||
private async sendCallMemberEvent(
|
||||
roomId: string,
|
||||
callId: string,
|
||||
active: boolean
|
||||
): Promise<void> {
|
||||
const stateKey = this.config.userId;
|
||||
|
||||
if (active) {
|
||||
const content: CallMemberEventContent = {
|
||||
'm.calls': [
|
||||
{
|
||||
'm.call_id': callId,
|
||||
'm.devices': [
|
||||
{
|
||||
device_id: this.config.deviceId,
|
||||
session_id: callId,
|
||||
expires_ts: Date.now() + 1000 * 60 * 60, // 1 hour expiry
|
||||
feeds: [
|
||||
{ purpose: 'usermedia' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (this.matrixClient as any).sendStateEvent(
|
||||
roomId,
|
||||
CALL_MEMBER_EVENT_TYPE,
|
||||
content,
|
||||
stateKey
|
||||
);
|
||||
} else {
|
||||
// Clear the call membership by sending empty calls array
|
||||
const content: CallMemberEventContent = {
|
||||
'm.calls': [],
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (this.matrixClient as any).sendStateEvent(
|
||||
roomId,
|
||||
CALL_MEMBER_EVENT_TYPE,
|
||||
content,
|
||||
stateKey
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a call in the specified room
|
||||
* @param roomId - The Matrix room ID to start the call in
|
||||
* @param callType - Voice or video call
|
||||
*/
|
||||
async startCall(roomId: string, callType: CallType): Promise<void> {
|
||||
if (this.activeCall) {
|
||||
throw new Error('A call is already active');
|
||||
}
|
||||
|
||||
const callId = this.generateCallId();
|
||||
|
||||
this.activeCall = {
|
||||
roomId,
|
||||
callType,
|
||||
state: CallState.Connecting,
|
||||
startTime: Date.now(),
|
||||
remoteStreams: new Map(),
|
||||
remoteScreenStreams: new Map(),
|
||||
remoteScreenTracks: new Map(),
|
||||
participants: [],
|
||||
isMuted: false,
|
||||
isVideoEnabled: callType === CallType.Video,
|
||||
isScreenSharing: false,
|
||||
};
|
||||
|
||||
this.emit({
|
||||
type: CallEventType.StateChanged,
|
||||
roomId,
|
||||
data: { state: CallState.Connecting },
|
||||
});
|
||||
|
||||
try {
|
||||
// Step 1: Get an OpenID token from the Matrix homeserver
|
||||
const openIdToken = await requestOpenIdToken(
|
||||
this.config.homeserverBaseUrl,
|
||||
this.config.userId,
|
||||
this.config.accessToken
|
||||
);
|
||||
|
||||
// Step 2: Exchange OpenID token for LiveKit JWT
|
||||
console.log('Requesting LiveKit JWT for Matrix room:', roomId);
|
||||
this.livekitJwt = await fetchLiveKitJWT(
|
||||
this.config.livekitServiceUrl,
|
||||
roomId,
|
||||
this.config.userId,
|
||||
this.config.deviceId,
|
||||
openIdToken
|
||||
);
|
||||
|
||||
console.log('Got LiveKit JWT, connecting to:', this.livekitJwt.url);
|
||||
console.log('LiveKit JWT response:', JSON.stringify(this.livekitJwt));
|
||||
|
||||
// Step 3: Create and connect to LiveKit room
|
||||
this.livekitRoom = new Room({
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
});
|
||||
|
||||
this.setupLiveKitEventHandlers();
|
||||
|
||||
await this.livekitRoom.connect(this.livekitJwt.url, this.livekitJwt.jwt);
|
||||
|
||||
console.log('Connected to LiveKit room');
|
||||
console.log('LiveKit room name:', this.livekitRoom.name);
|
||||
console.log('Local participant identity:', this.livekitRoom.localParticipant.identity);
|
||||
console.log('Remote participants:', this.livekitRoom.remoteParticipants.size);
|
||||
|
||||
// Get user's audio device preferences
|
||||
const audioSettings = getAudioSettings();
|
||||
console.log('Using audio settings:', audioSettings);
|
||||
|
||||
// Build audio capture options with processing settings
|
||||
const audioCaptureOptions = {
|
||||
deviceId: audioSettings.microphoneId || undefined,
|
||||
noiseSuppression: audioSettings.noiseSuppression,
|
||||
echoCancellation: audioSettings.echoCancellation,
|
||||
autoGainControl: audioSettings.autoGainControl,
|
||||
};
|
||||
|
||||
console.log('Audio capture options:', audioCaptureOptions);
|
||||
|
||||
// Step 4: Publish local tracks based on call type with selected devices and processing
|
||||
if (callType === CallType.Video) {
|
||||
// For video calls, enable both camera and microphone
|
||||
await this.livekitRoom.localParticipant.setCameraEnabled(true);
|
||||
await this.livekitRoom.localParticipant.setMicrophoneEnabled(true, audioCaptureOptions);
|
||||
} else {
|
||||
// Voice call - only enable microphone with preferred device and processing
|
||||
await this.livekitRoom.localParticipant.setMicrophoneEnabled(true, audioCaptureOptions);
|
||||
}
|
||||
|
||||
// Step 5: Send Matrix state event to announce participation
|
||||
await this.sendCallMemberEvent(roomId, callId, true);
|
||||
|
||||
console.log('Announced call participation to Matrix room');
|
||||
|
||||
this.setState(CallState.Connected);
|
||||
} catch (error) {
|
||||
console.error('Failed to start call:', error);
|
||||
this.setState(CallState.Error);
|
||||
this.emit({
|
||||
type: CallEventType.Error,
|
||||
roomId,
|
||||
data: { error },
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up LiveKit room event handlers
|
||||
*/
|
||||
private setupLiveKitEventHandlers(): void {
|
||||
if (!this.livekitRoom) return;
|
||||
|
||||
this.livekitRoom.on(RoomEvent.ConnectionStateChanged, (state: ConnectionState) => {
|
||||
console.log('LiveKit connection state:', state);
|
||||
|
||||
if (!this.activeCall) return;
|
||||
|
||||
switch (state) {
|
||||
case ConnectionState.Connected:
|
||||
this.setState(CallState.Connected);
|
||||
break;
|
||||
case ConnectionState.Reconnecting:
|
||||
this.setState(CallState.Reconnecting);
|
||||
break;
|
||||
case ConnectionState.Disconnected:
|
||||
this.setState(CallState.Ended);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
this.livekitRoom.on(
|
||||
RoomEvent.TrackSubscribed,
|
||||
(
|
||||
track: RemoteTrackPublication['track'],
|
||||
publication: RemoteTrackPublication,
|
||||
participant: RemoteParticipant
|
||||
) => {
|
||||
if (!track || !this.activeCall) return;
|
||||
|
||||
const isScreenShare = publication.source === Track.Source.ScreenShare ||
|
||||
publication.source === Track.Source.ScreenShareAudio;
|
||||
|
||||
console.log('Track subscribed:', track.kind, 'source:', publication.source,
|
||||
'isScreenShare:', isScreenShare, 'from', participant.identity);
|
||||
console.log('Track mediaStreamTrack:', track.mediaStreamTrack);
|
||||
console.log('Track mediaStreamTrack readyState:', track.mediaStreamTrack?.readyState);
|
||||
console.log('Track mediaStreamTrack enabled:', track.mediaStreamTrack?.enabled);
|
||||
|
||||
// For audio tracks, attach to an audio element to play
|
||||
if (track.kind === 'audio') {
|
||||
const audioElement = track.attach() as HTMLAudioElement;
|
||||
audioElement.id = `audio-${participant.identity}-${publication.source}`;
|
||||
|
||||
// Set the preferred output device if supported and selected
|
||||
const audioSettings = getAudioSettings();
|
||||
if (audioSettings.speakerId && 'setSinkId' in audioElement) {
|
||||
(audioElement as HTMLAudioElement & { setSinkId: (id: string) => Promise<void> })
|
||||
.setSinkId(audioSettings.speakerId)
|
||||
.then(() => console.log('Speaker set to:', audioSettings.speakerId))
|
||||
.catch((e) => console.warn('Could not set speaker device:', e));
|
||||
}
|
||||
|
||||
document.body.appendChild(audioElement);
|
||||
console.log('Audio element attached for', participant.identity);
|
||||
}
|
||||
|
||||
// Handle screen share video tracks separately
|
||||
if (isScreenShare && track.kind === 'video') {
|
||||
const mediaStream = new MediaStream([track.mediaStreamTrack]);
|
||||
|
||||
console.log('📺 Screen share received from', participant.identity);
|
||||
console.log('📺 Screen share track:', track);
|
||||
console.log('📺 Screen share MediaStream:', mediaStream);
|
||||
console.log('📺 Screen share MediaStream tracks:', mediaStream?.getTracks());
|
||||
|
||||
this.activeCall.remoteScreenStreams.set(participant.identity, mediaStream);
|
||||
|
||||
// Store the actual track for proper attachment later
|
||||
this.activeCall.remoteScreenTracks.set(participant.identity, {
|
||||
participantId: participant.identity,
|
||||
track,
|
||||
stream: mediaStream,
|
||||
});
|
||||
|
||||
this.emit({
|
||||
type: CallEventType.ScreenShareStarted,
|
||||
roomId: this.activeCall.roomId,
|
||||
data: {
|
||||
participantId: participant.identity,
|
||||
stream: mediaStream,
|
||||
track,
|
||||
},
|
||||
});
|
||||
} else if (track.kind === 'video') {
|
||||
const mediaStream = new MediaStream([track.mediaStreamTrack]);
|
||||
this.activeCall.remoteStreams.set(participant.identity, mediaStream);
|
||||
|
||||
this.emit({
|
||||
type: CallEventType.RemoteStreamAdded,
|
||||
roomId: this.activeCall.roomId,
|
||||
data: {
|
||||
participantId: participant.identity,
|
||||
stream: mediaStream,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Update participants list
|
||||
if (!this.activeCall.participants.includes(participant.identity)) {
|
||||
this.activeCall.participants.push(participant.identity);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
this.livekitRoom.on(
|
||||
RoomEvent.TrackUnsubscribed,
|
||||
(
|
||||
track: RemoteTrackPublication['track'],
|
||||
publication: RemoteTrackPublication,
|
||||
participant: RemoteParticipant
|
||||
) => {
|
||||
if (!this.activeCall) return;
|
||||
|
||||
const isScreenShare = publication.source === Track.Source.ScreenShare ||
|
||||
publication.source === Track.Source.ScreenShareAudio;
|
||||
|
||||
console.log('Track unsubscribed:', track?.kind, 'source:', publication.source,
|
||||
'isScreenShare:', isScreenShare, 'from', participant.identity);
|
||||
|
||||
// Remove audio element if it exists
|
||||
if (track?.kind === 'audio') {
|
||||
const audioElement = document.getElementById(`audio-${participant.identity}-${publication.source}`);
|
||||
if (audioElement) {
|
||||
audioElement.remove();
|
||||
console.log('Audio element removed for', participant.identity);
|
||||
}
|
||||
track.detach();
|
||||
}
|
||||
|
||||
// Handle screen share removal
|
||||
if (isScreenShare && track?.kind === 'video') {
|
||||
// Detach the track from any elements
|
||||
track.detach();
|
||||
this.activeCall.remoteScreenStreams.delete(participant.identity);
|
||||
this.activeCall.remoteScreenTracks.delete(participant.identity);
|
||||
console.log('📺 Screen share ended from', participant.identity);
|
||||
|
||||
this.emit({
|
||||
type: CallEventType.ScreenShareStopped,
|
||||
roomId: this.activeCall.roomId,
|
||||
data: { participantId: participant.identity },
|
||||
});
|
||||
} else if (track?.kind === 'video') {
|
||||
this.activeCall.remoteStreams.delete(participant.identity);
|
||||
|
||||
this.emit({
|
||||
type: CallEventType.RemoteStreamRemoved,
|
||||
roomId: this.activeCall.roomId,
|
||||
data: { participantId: participant.identity },
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
this.livekitRoom.on(RoomEvent.ParticipantConnected, (participant: RemoteParticipant) => {
|
||||
console.log('Participant connected:', participant.identity, 'in Matrix room:', this.activeCall?.roomId);
|
||||
console.log('Current LiveKit room name:', this.livekitRoom?.name);
|
||||
|
||||
// Listen for this participant's speaking state
|
||||
participant.on(ParticipantEvent.IsSpeakingChanged, (speaking: boolean) => {
|
||||
if (speaking) {
|
||||
console.log(`🔊 ${participant.identity} is speaking`);
|
||||
} else {
|
||||
console.log(`🔈 ${participant.identity} stopped speaking`);
|
||||
}
|
||||
});
|
||||
|
||||
if (this.activeCall && !this.activeCall.participants.includes(participant.identity)) {
|
||||
this.activeCall.participants.push(participant.identity);
|
||||
this.emit({
|
||||
type: CallEventType.ParticipantJoined,
|
||||
roomId: this.activeCall.roomId,
|
||||
data: { participantId: participant.identity },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.livekitRoom.on(RoomEvent.ParticipantDisconnected, (participant: RemoteParticipant) => {
|
||||
console.log('Participant disconnected:', participant.identity);
|
||||
|
||||
if (this.activeCall) {
|
||||
this.activeCall.participants = this.activeCall.participants.filter(
|
||||
(p) => p !== participant.identity
|
||||
);
|
||||
this.activeCall.remoteStreams.delete(participant.identity);
|
||||
|
||||
this.emit({
|
||||
type: CallEventType.ParticipantLeft,
|
||||
roomId: this.activeCall.roomId,
|
||||
data: { participantId: participant.identity },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.livekitRoom.on(RoomEvent.LocalTrackPublished, (publication) => {
|
||||
console.log('Local track published:', publication.kind);
|
||||
|
||||
if (this.activeCall && publication.track) {
|
||||
const mediaStream = new MediaStream([publication.track.mediaStreamTrack]);
|
||||
this.activeCall.localStream = mediaStream;
|
||||
}
|
||||
});
|
||||
|
||||
// Voice activity detection - logs when speakers change
|
||||
this.livekitRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers: Participant[]) => {
|
||||
if (speakers.length > 0) {
|
||||
const speakerNames = speakers.map((s) => s.identity).join(', ');
|
||||
console.log('🎤 Active speakers:', speakerNames);
|
||||
}
|
||||
});
|
||||
|
||||
// Track local participant speaking state
|
||||
this.livekitRoom.localParticipant.on(ParticipantEvent.IsSpeakingChanged, (speaking: boolean) => {
|
||||
if (speaking) {
|
||||
console.log('🎙️ You are speaking');
|
||||
} else {
|
||||
console.log('🔇 You stopped speaking');
|
||||
}
|
||||
});
|
||||
|
||||
// Track audio level changes for local participant (more detailed)
|
||||
this.livekitRoom.localParticipant.on(ParticipantEvent.AudioStreamAcquired, () => {
|
||||
console.log('🎧 Audio stream acquired');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the current call
|
||||
*/
|
||||
async endCall(): Promise<void> {
|
||||
if (!this.activeCall) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { roomId } = this.activeCall;
|
||||
this.setState(CallState.Disconnecting);
|
||||
|
||||
try {
|
||||
// Clear Matrix call membership
|
||||
await this.sendCallMemberEvent(roomId, '', false);
|
||||
} catch (error) {
|
||||
console.error('Failed to clear call membership:', error);
|
||||
}
|
||||
|
||||
// Clean up all audio elements
|
||||
document.querySelectorAll('[id^="audio-"]').forEach((el) => el.remove());
|
||||
|
||||
// Disconnect from LiveKit
|
||||
if (this.livekitRoom) {
|
||||
this.livekitRoom.disconnect();
|
||||
this.livekitRoom = null;
|
||||
}
|
||||
|
||||
this.activeCall = null;
|
||||
this.livekitJwt = null;
|
||||
|
||||
this.emit({
|
||||
type: CallEventType.StateChanged,
|
||||
roomId,
|
||||
data: { state: CallState.Ended },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles audio mute state
|
||||
*/
|
||||
toggleMute(): boolean {
|
||||
if (!this.livekitRoom?.localParticipant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const newMuteState = !this.activeCall?.isMuted;
|
||||
this.livekitRoom.localParticipant.setMicrophoneEnabled(!newMuteState);
|
||||
|
||||
if (this.activeCall) {
|
||||
this.activeCall.isMuted = newMuteState;
|
||||
}
|
||||
|
||||
return newMuteState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles video enabled state
|
||||
*/
|
||||
toggleVideo(): boolean {
|
||||
if (!this.livekitRoom?.localParticipant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const newVideoState = !this.activeCall?.isVideoEnabled;
|
||||
this.livekitRoom.localParticipant.setCameraEnabled(newVideoState);
|
||||
|
||||
if (this.activeCall) {
|
||||
this.activeCall.isVideoEnabled = newVideoState;
|
||||
}
|
||||
|
||||
return newVideoState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the LiveKit JWT for the current call
|
||||
*/
|
||||
getLiveKitCredentials(): LiveKitJWTResponse | null {
|
||||
return this.livekitJwt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the LiveKit room instance
|
||||
*/
|
||||
getLiveKitRoom(): Room | null {
|
||||
return this.livekitRoom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts screen sharing with configurable resolution and bitrate
|
||||
* @returns true if screen share started successfully
|
||||
*/
|
||||
async startScreenShare(): Promise<boolean> {
|
||||
if (!this.livekitRoom?.localParticipant || !this.activeCall) {
|
||||
console.warn('Cannot start screen share: no active call or room');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.activeCall.isScreenSharing) {
|
||||
console.warn('Screen sharing is already active');
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// Preserve current mute state before starting screen share
|
||||
const wasMuted = this.activeCall.isMuted;
|
||||
|
||||
const audioSettings = getAudioSettings();
|
||||
const resolution = SCREEN_SHARE_RESOLUTIONS[audioSettings.screenShareResolution];
|
||||
|
||||
console.log('Starting screen share with settings:', {
|
||||
resolution: audioSettings.screenShareResolution,
|
||||
bitrate: audioSettings.screenShareBitrate,
|
||||
frameRate: audioSettings.screenShareFrameRate,
|
||||
});
|
||||
|
||||
// Build screen share options - LiveKit uses different format
|
||||
// Resolution and framerate are set via constraints when creating track
|
||||
// Note: audio here is for system/tab audio, not microphone
|
||||
const screenShareOptions = {
|
||||
audio: true, // Include system audio if available
|
||||
resolution: audioSettings.screenShareResolution === 'source'
|
||||
? undefined
|
||||
: {
|
||||
width: resolution.width,
|
||||
height: resolution.height,
|
||||
frameRate: audioSettings.screenShareFrameRate,
|
||||
},
|
||||
};
|
||||
|
||||
// Enable screen share with the configured options
|
||||
await this.livekitRoom.localParticipant.setScreenShareEnabled(true, screenShareOptions);
|
||||
|
||||
// Restore mute state - LiveKit might have changed mic state
|
||||
if (wasMuted) {
|
||||
this.livekitRoom.localParticipant.setMicrophoneEnabled(false);
|
||||
}
|
||||
|
||||
// Set bitrate on the screen share track publication
|
||||
const screenSharePub = Array.from(
|
||||
this.livekitRoom.localParticipant.trackPublications.values()
|
||||
).find(
|
||||
(pub): pub is LocalTrackPublication =>
|
||||
pub.source === Track.Source.ScreenShare
|
||||
);
|
||||
|
||||
if (screenSharePub?.track) {
|
||||
console.log(`Screen share started with bitrate: ${SCREEN_SHARE_BITRATES[audioSettings.screenShareBitrate].bitrate}kbps`);
|
||||
// LiveKit handles bitrate via simulcast and adaptive streaming
|
||||
}
|
||||
|
||||
this.activeCall.isScreenSharing = true;
|
||||
|
||||
this.emit({
|
||||
type: CallEventType.StateChanged,
|
||||
roomId: this.activeCall.roomId,
|
||||
data: { state: this.activeCall.state, isScreenSharing: true },
|
||||
});
|
||||
|
||||
console.log('✅ Screen sharing started');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to start screen share:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops screen sharing
|
||||
* @returns true if screen share stopped successfully
|
||||
*/
|
||||
async stopScreenShare(): Promise<boolean> {
|
||||
if (!this.livekitRoom?.localParticipant || !this.activeCall) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.activeCall.isScreenSharing) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.livekitRoom.localParticipant.setScreenShareEnabled(false);
|
||||
this.activeCall.isScreenSharing = false;
|
||||
|
||||
this.emit({
|
||||
type: CallEventType.StateChanged,
|
||||
roomId: this.activeCall.roomId,
|
||||
data: { state: this.activeCall.state, isScreenSharing: false },
|
||||
});
|
||||
|
||||
console.log('🛑 Screen sharing stopped');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to stop screen share:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles screen sharing on/off
|
||||
* @returns The new screen sharing state
|
||||
*/
|
||||
async toggleScreenShare(): Promise<boolean> {
|
||||
if (this.activeCall?.isScreenSharing) {
|
||||
await this.stopScreenShare();
|
||||
return false;
|
||||
}
|
||||
return this.startScreenShare();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current screen sharing state
|
||||
*/
|
||||
isScreenSharing(): boolean {
|
||||
return this.activeCall?.isScreenSharing ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up resources
|
||||
*/
|
||||
dispose(): void {
|
||||
this.endCall();
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user