| /* |
| * Copyright (C) 2022 Savoir-faire Linux Inc. |
| * |
| * This program is free software; you can redistribute it and/or modify |
| * it under the terms of the GNU Affero General Public License as |
| * published by the Free Software Foundation; either version 3 of the |
| * License, or (at your option) any later version. |
| * |
| * This program is distributed in the hope that it will be useful, |
| * but WITHOUT ANY WARRANTY; without even the implied warranty of |
| * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| * GNU Affero General Public License for more details. |
| * |
| * You should have received a copy of the GNU Affero General Public |
| * License along with this program. If not, see |
| * <https://www.gnu.org/licenses/>. |
| */ |
| import { CallAction, CallBegin, WebSocketMessageType } from 'jami-web-common'; |
| import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; |
| import { Navigate, useNavigate } from 'react-router-dom'; |
| |
| import { useUrlParams } from '../hooks/useUrlParams'; |
| import { CallRouteParams } from '../router'; |
| import { callTimeoutMs } from '../utils/constants'; |
| import { SetState, WithChildren } from '../utils/utils'; |
| import { ConversationContext } from './ConversationProvider'; |
| import { WebRtcContext } from './WebRtcProvider'; |
| import { WebSocketContext } from './WebSocketProvider'; |
| |
| export type CallRole = 'caller' | 'receiver'; |
| |
| export enum CallStatus { |
| Default, |
| Ringing, |
| Connecting, |
| InCall, |
| } |
| |
| export interface ICallContext { |
| mediaDevices: Record<MediaDeviceKind, MediaDeviceInfo[]>; |
| |
| localStream: MediaStream | undefined; |
| remoteStream: MediaStream | undefined; // TODO: should be an array of participants. find a way to map MediaStream id to contactid https://stackoverflow.com/a/68663155/6592293 |
| |
| isAudioOn: boolean; |
| setAudioStatus: (isOn: boolean) => void; |
| isVideoOn: boolean; |
| setVideoStatus: (isOn: boolean) => void; |
| isChatShown: boolean; |
| setIsChatShown: SetState<boolean>; |
| isFullscreen: boolean; |
| setIsFullscreen: SetState<boolean>; |
| callRole: CallRole; |
| callStatus: CallStatus; |
| callStartTime: Date | undefined; |
| |
| acceptCall: (withVideoOn: boolean) => void; |
| endCall: () => void; |
| } |
| |
| const defaultCallContext: ICallContext = { |
| mediaDevices: { |
| audioinput: [], |
| audiooutput: [], |
| videoinput: [], |
| }, |
| |
| localStream: undefined, |
| remoteStream: undefined, |
| |
| isAudioOn: false, |
| setAudioStatus: () => {}, |
| isVideoOn: false, |
| setVideoStatus: () => {}, |
| isChatShown: false, |
| setIsChatShown: () => {}, |
| isFullscreen: false, |
| setIsFullscreen: () => {}, |
| callRole: 'caller', |
| callStatus: CallStatus.Default, |
| callStartTime: undefined, |
| |
| acceptCall: (_: boolean) => {}, |
| endCall: () => {}, |
| }; |
| |
| export const CallContext = createContext<ICallContext>(defaultCallContext); |
| |
| export default ({ children }: WithChildren) => { |
| const { |
| queryParams: { role: callRole }, |
| state: routeState, |
| } = useUrlParams<CallRouteParams>(); |
| const webSocket = useContext(WebSocketContext); |
| const { webRtcConnection, remoteStreams, sendWebRtcOffer, isConnected } = useContext(WebRtcContext); |
| const { conversationId, conversation } = useContext(ConversationContext); |
| const navigate = useNavigate(); |
| |
| const [mediaDevices, setMediaDevices] = useState<Record<MediaDeviceKind, MediaDeviceInfo[]>>( |
| defaultCallContext.mediaDevices |
| ); |
| const [localStream, setLocalStream] = useState<MediaStream>(); |
| |
| const [isAudioOn, setIsAudioOn] = useState(false); |
| const [isVideoOn, setIsVideoOn] = useState(false); |
| const [isChatShown, setIsChatShown] = useState(false); |
| const [isFullscreen, setIsFullscreen] = useState(false); |
| const [callStatus, setCallStatus] = useState(routeState?.callStatus); |
| const [callStartTime, setCallStartTime] = useState<Date | undefined>(undefined); |
| |
| // TODO: This logic will have to change to support multiple people in a call. Could we move this logic to the server? |
| // The client could make a single request with the conversationId, and the server would be tasked with sending |
| // all the individual requests to the members of the conversation. |
| const contactUri = useMemo(() => conversation.getFirstMember().contact.getUri(), [conversation]); |
| |
| useEffect(() => { |
| try { |
| // TODO: Wait until status is `InCall` before getting devices |
| navigator.mediaDevices.enumerateDevices().then((devices) => { |
| const newMediaDevices: Record<MediaDeviceKind, MediaDeviceInfo[]> = { |
| audioinput: [], |
| audiooutput: [], |
| videoinput: [], |
| }; |
| |
| for (const device of devices) { |
| newMediaDevices[device.kind].push(device); |
| } |
| |
| setMediaDevices(newMediaDevices); |
| }); |
| } catch (e) { |
| console.error('Could not get media devices:', e); |
| } |
| |
| try { |
| navigator.mediaDevices |
| .getUserMedia({ |
| audio: true, // TODO: Set both to false by default |
| video: true, |
| }) |
| .then((stream) => { |
| for (const track of stream.getTracks()) { |
| // TODO: Set default from isVideoOn and isMicOn values |
| track.enabled = false; |
| } |
| setLocalStream(stream); |
| }); |
| } catch (e) { |
| // TODO: Better handle user denial |
| console.error('Could not get media devices:', e); |
| } |
| }, []); |
| |
| useEffect(() => { |
| if (localStream && webRtcConnection) { |
| for (const track of localStream.getTracks()) { |
| webRtcConnection.addTrack(track, localStream); |
| } |
| } |
| }, [localStream, webRtcConnection]); |
| |
| const setAudioStatus = useCallback( |
| (isOn: boolean) => { |
| if (!localStream) { |
| return; |
| } |
| |
| for (const track of localStream.getAudioTracks()) { |
| track.enabled = isOn; |
| } |
| |
| setIsAudioOn(isOn); |
| }, |
| [localStream] |
| ); |
| |
| const setVideoStatus = useCallback( |
| (isOn: boolean) => { |
| if (!localStream) { |
| return; |
| } |
| |
| for (const track of localStream.getVideoTracks()) { |
| track.enabled = isOn; |
| } |
| |
| setIsVideoOn(isOn); |
| }, |
| [localStream] |
| ); |
| |
| useEffect(() => { |
| if (!webSocket) { |
| return; |
| } |
| |
| if (callRole === 'caller' && callStatus === CallStatus.Default) { |
| const callBegin: CallBegin = { |
| contactId: contactUri, |
| conversationId, |
| withVideoOn: routeState?.isVideoOn ?? false, |
| }; |
| |
| console.info('Sending CallBegin', callBegin); |
| webSocket.send(WebSocketMessageType.CallBegin, callBegin); |
| setCallStatus(CallStatus.Ringing); |
| setIsVideoOn(routeState?.isVideoOn ?? false); |
| } |
| }, [webSocket, callRole, callStatus, contactUri, conversationId, routeState]); |
| |
| useEffect(() => { |
| const onFullscreenChange = () => { |
| setIsFullscreen(document.fullscreenElement !== null); |
| }; |
| |
| document.addEventListener('fullscreenchange', onFullscreenChange); |
| return () => { |
| document.removeEventListener('fullscreenchange', onFullscreenChange); |
| }; |
| }, []); |
| |
| useEffect(() => { |
| if (!webSocket || !webRtcConnection) { |
| return; |
| } |
| |
| if (callRole === 'caller' && callStatus === CallStatus.Ringing) { |
| const callAcceptListener = (data: CallAction) => { |
| console.info('Received event on CallAccept', data); |
| if (data.conversationId !== conversationId) { |
| console.warn('Wrong incoming conversationId, ignoring action'); |
| return; |
| } |
| |
| setCallStatus(CallStatus.Connecting); |
| |
| webRtcConnection |
| .createOffer({ |
| offerToReceiveAudio: true, |
| offerToReceiveVideo: true, |
| }) |
| .then((sdp) => { |
| sendWebRtcOffer(sdp); |
| }); |
| }; |
| |
| webSocket.bind(WebSocketMessageType.CallAccept, callAcceptListener); |
| |
| return () => { |
| webSocket.unbind(WebSocketMessageType.CallAccept, callAcceptListener); |
| }; |
| } |
| }, [callRole, webSocket, webRtcConnection, sendWebRtcOffer, callStatus, conversationId]); |
| |
| const quitCall = useCallback(() => { |
| if (!webRtcConnection) { |
| throw new Error('Could not quit call: webRtcConnection is not defined'); |
| } |
| |
| const localTracks = localStream?.getTracks(); |
| if (localTracks) { |
| for (const track of localTracks) { |
| track.stop(); |
| } |
| } |
| |
| webRtcConnection.close(); |
| navigate(`/conversation/${conversationId}`); |
| }, [webRtcConnection, localStream, navigate, conversationId]); |
| |
| useEffect(() => { |
| if (!webSocket) { |
| return; |
| } |
| |
| const callEndListener = (data: CallAction) => { |
| console.info('Received event on CallEnd', data); |
| if (data.conversationId !== conversationId) { |
| console.warn('Wrong incoming conversationId, ignoring action'); |
| return; |
| } |
| |
| quitCall(); |
| // TODO: write in chat that the call ended |
| }; |
| |
| webSocket.bind(WebSocketMessageType.CallEnd, callEndListener); |
| return () => { |
| webSocket.unbind(WebSocketMessageType.CallEnd, callEndListener); |
| }; |
| }, [webSocket, navigate, conversationId, quitCall]); |
| |
| useEffect(() => { |
| if (callStatus === CallStatus.Connecting && isConnected) { |
| console.info('Changing call status to InCall'); |
| setCallStatus(CallStatus.InCall); |
| setVideoStatus(isVideoOn); |
| setCallStartTime(new Date()); |
| } |
| }, [isConnected, callStatus, setVideoStatus, isVideoOn]); |
| |
| const acceptCall = useCallback( |
| (withVideoOn: boolean) => { |
| if (!webSocket) { |
| throw new Error('Could not accept call'); |
| } |
| |
| const callAccept: CallAction = { |
| contactId: contactUri, |
| conversationId, |
| }; |
| |
| console.info('Sending CallAccept', callAccept); |
| webSocket.send(WebSocketMessageType.CallAccept, callAccept); |
| setIsVideoOn(withVideoOn); |
| setCallStatus(CallStatus.Connecting); |
| }, |
| [webSocket, contactUri, conversationId] |
| ); |
| |
| const endCall = useCallback(() => { |
| if (!webSocket) { |
| throw new Error('Could not end call'); |
| } |
| |
| const callEnd: CallAction = { |
| contactId: contactUri, |
| conversationId, |
| }; |
| |
| console.info('Sending CallEnd', callEnd); |
| webSocket.send(WebSocketMessageType.CallEnd, callEnd); |
| quitCall(); |
| // TODO: write in chat that the call ended |
| }, [webSocket, contactUri, conversationId, quitCall]); |
| |
| useEffect(() => { |
| const checkStatusTimeout = () => { |
| if (callStatus !== CallStatus.InCall) { |
| endCall(); |
| } |
| }; |
| const timeoutId = setTimeout(checkStatusTimeout, callTimeoutMs); |
| |
| return () => { |
| clearTimeout(timeoutId); |
| }; |
| }, [callStatus, endCall]); |
| |
| if (!callRole || callStatus === undefined) { |
| console.error('Invalid route. Redirecting...'); |
| return <Navigate to={'/'} />; |
| } |
| |
| return ( |
| <CallContext.Provider |
| value={{ |
| mediaDevices, |
| localStream, |
| remoteStream: remoteStreams?.at(-1), |
| isAudioOn, |
| setAudioStatus, |
| isVideoOn, |
| setVideoStatus, |
| isChatShown, |
| setIsChatShown, |
| isFullscreen, |
| setIsFullscreen, |
| callRole, |
| callStatus, |
| callStartTime, |
| acceptCall, |
| endCall, |
| }} |
| > |
| {children} |
| </CallContext.Provider> |
| ); |
| }; |