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:
2026-01-23 19:35:25 +11:00
parent c88cb4bca9
commit 94f8466d1c
19 changed files with 3270 additions and 14 deletions

View File

@@ -0,0 +1,238 @@
import { createContext, useContext, useState, useCallback, useEffect, useMemo } from 'react';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { CallService } from './CallService';
import { ActiveCall, CallEvent, CallEventType, CallState, CallType } from './types';
import { fetchWellKnownWithRTC, getLiveKitFocus } from './useRtcConfig';
interface CallContextValue {
callService: CallService | null;
activeCall: ActiveCall | null;
callSupported: boolean;
callSupportLoading: boolean;
startCall: (roomId: string, callType: CallType) => Promise<void>;
endCall: () => Promise<void>;
toggleMute: () => boolean;
toggleVideo: () => boolean;
toggleScreenShare: () => Promise<boolean>;
}
const CallContext = createContext<CallContextValue | null>(null);
/**
* Hook to access the call context
* @returns The call context value
*/
export function useCall(): CallContextValue {
const context = useContext(CallContext);
if (!context) {
throw new Error('useCall must be used within a CallProvider');
}
return context;
}
export const CallProvider = CallContext.Provider;
interface UseCallServiceOptions {
livekitServiceUrl?: string;
}
/**
* Hook to initialize and manage the call service
* Should be used at the app level to provide call context
* @param options - Optional configuration including livekitServiceUrl from client config
*/
export function useCallService(options?: UseCallServiceOptions): CallContextValue {
const mx = useMatrixClient();
const [callService, setCallService] = useState<CallService | null>(null);
const [activeCall, setActiveCall] = useState<ActiveCall | null>(null);
const [callSupported, setCallSupported] = useState(false);
const [callSupportLoading, setCallSupportLoading] = useState(true);
const configuredLivekitUrl = options?.livekitServiceUrl;
useEffect(() => {
let unsubscribe: (() => void) | undefined;
let service: CallService | undefined;
const initCallService = async () => {
const baseUrl = mx.getHomeserverUrl();
const accessToken = mx.getAccessToken();
const userId = mx.getSafeUserId();
const deviceId = mx.getDeviceId();
if (!accessToken || !deviceId) {
setCallSupportLoading(false);
return;
}
try {
let livekitServiceUrl: string | undefined = configuredLivekitUrl;
// If no configured URL, try to get from well-known
if (!livekitServiceUrl) {
const wellKnown = await fetchWellKnownWithRTC(baseUrl);
// eslint-disable-next-line no-console
console.log('Call service init - well-known:', wellKnown);
const livekitFocus = getLiveKitFocus(wellKnown);
// eslint-disable-next-line no-console
console.log('Call service init - LiveKit focus:', livekitFocus);
livekitServiceUrl = livekitFocus?.livekit_service_url;
} else {
// eslint-disable-next-line no-console
console.log('Call service init - Using configured LiveKit URL:', livekitServiceUrl);
}
if (!livekitServiceUrl) {
// eslint-disable-next-line no-console
console.log('Call service init - No LiveKit service found, calls not supported');
setCallSupported(false);
setCallSupportLoading(false);
return;
}
service = new CallService(
{
livekitServiceUrl,
homeserverBaseUrl: baseUrl,
accessToken,
userId,
deviceId,
},
mx
);
setCallService(service);
setCallSupported(true);
setCallSupportLoading(false);
// eslint-disable-next-line no-console
console.log('Call service init - Success! Calls are supported');
unsubscribe = service.on((event: CallEvent) => {
// Update state for any event that might change the call state
if (event.type === CallEventType.StateChanged ||
event.type === CallEventType.ScreenShareStarted ||
event.type === CallEventType.ScreenShareStopped ||
event.type === CallEventType.RemoteStreamAdded ||
event.type === CallEventType.RemoteStreamRemoved) {
const newCall = service?.getActiveCall();
if (newCall) {
// Deep copy Maps to ensure React detects changes
setActiveCall({
...newCall,
remoteStreams: new Map(newCall.remoteStreams),
remoteScreenTracks: new Map(newCall.remoteScreenTracks),
remoteScreenStreams: new Map(newCall.remoteScreenStreams),
});
} else {
setActiveCall(null);
}
}
});
} catch (error) {
// eslint-disable-next-line no-console
console.error('Call service init - Error:', error);
setCallSupported(false);
setCallSupportLoading(false);
}
};
initCallService();
return () => {
unsubscribe?.();
service?.dispose();
};
}, [mx, configuredLivekitUrl]);
const startCall = useCallback(
async (roomId: string, callType: CallType) => {
if (!callService) {
throw new Error('Call service not initialized');
}
await callService.startCall(roomId, callType);
},
[callService]
);
const endCall = useCallback(async () => {
if (!callService) {
return;
}
await callService.endCall();
}, [callService]);
const toggleMute = useCallback(() => {
if (!callService) {
return false;
}
const newState = callService.toggleMute();
const call = callService.getActiveCall();
if (call) {
setActiveCall({ ...call });
}
return newState;
}, [callService]);
const toggleVideo = useCallback(() => {
if (!callService) {
return false;
}
const newState = callService.toggleVideo();
const call = callService.getActiveCall();
if (call) {
setActiveCall({ ...call });
}
return newState;
}, [callService]);
const toggleScreenShare = useCallback(async () => {
if (!callService) {
return false;
}
const newState = await callService.toggleScreenShare();
const call = callService.getActiveCall();
if (call) {
setActiveCall({ ...call });
}
return newState;
}, [callService]);
return useMemo(
() => ({
callService,
activeCall,
callSupported,
callSupportLoading,
startCall,
endCall,
toggleMute,
toggleVideo,
toggleScreenShare,
}),
[callService, activeCall, callSupported, callSupportLoading, startCall, endCall, toggleMute, toggleVideo, toggleScreenShare]
);
}
/**
* Hook to check if a call is active in a specific room
*/
export function useRoomCall(roomId: string) {
const { activeCall, startCall, endCall, callSupported, callSupportLoading } = useCall();
const isInCall = activeCall?.roomId === roomId;
const callState = isInCall ? activeCall.state : CallState.Idle;
const startVoiceCall = useCallback(() => startCall(roomId, CallType.Voice), [roomId, startCall]);
const startVideoCall = useCallback(() => startCall(roomId, CallType.Video), [roomId, startCall]);
return {
isInCall,
callState,
activeCall: isInCall ? activeCall : null,
startVoiceCall,
startVideoCall,
endCall,
callSupported,
callSupportLoading,
};
}