blob: 99030720412141f31ffc78fb80ae1412b38262bb [file] [log] [blame]
idillon27dab022023-02-02 17:55:47 -05001/*
2 * Copyright (C) 2022 Savoir-faire Linux Inc.
3 *
4 * This program is free software; you can redistribute it and/or modify
5 * it under the terms of the GNU Affero General Public License as
6 * published by the Free Software Foundation; either version 3 of the
7 * License, or (at your option) any later version.
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU Affero General Public License for more details.
13 *
14 * You should have received a copy of the GNU Affero General Public
15 * License along with this program. If not, see
16 * <https://www.gnu.org/licenses/>.
17 */
18import { useCallback, useEffect, useMemo, useState } from 'react';
19
20import { createOptionalContext } from '../hooks/createOptionalContext';
21import { WithChildren } from '../utils/utils';
22
23type MediaDeviceIdState = {
24 id: string | undefined;
25 setId: (id: string | undefined) => void | Promise<void>;
26};
27type CurrentMediaDeviceIds = Record<MediaDeviceKind, MediaDeviceIdState>;
28
29export type MediaDevicesInfo = Record<MediaDeviceKind, MediaDeviceInfo[]>;
30export type MediaInputKind = 'audio' | 'video';
31export type MediaInputIds = Record<MediaInputKind, string | false | undefined>;
32
33type IUserMediaContext = {
34 localStream: MediaStream | undefined;
35 updateLocalStream: (mediaDeviceIds?: MediaInputIds) => Promise<MediaStream | undefined>;
36 screenShareLocalStream: MediaStream | undefined;
37 updateScreenShare: (isOn: boolean) => Promise<MediaStream | undefined>;
38
39 mediaDevices: MediaDevicesInfo;
40 currentMediaDeviceIds: CurrentMediaDeviceIds;
41
42 setAudioInputDeviceId: (id: string) => void;
43 setAudioOutputDeviceId: (id: string) => void;
44 setVideoDeviceId: (id: string) => void;
45
46 stopMedias: () => void;
47};
48
49const optionalUserMediaContext = createOptionalContext<IUserMediaContext>('UserMediaContext');
50export const useUserMediaContext = optionalUserMediaContext.useOptionalContext;
51
52export default ({ children }: WithChildren) => {
53 const [localStream, setLocalStream] = useState<MediaStream>();
54 const [screenShareLocalStream, setScreenShareLocalStream] = useState<MediaStream>();
55
56 const [mediaDevices, setMediaDevices] = useState<MediaDevicesInfo>({
57 audioinput: [],
58 audiooutput: [],
59 videoinput: [],
60 });
61 const [audioInputDeviceId, setAudioInputDeviceId] = useState<string>();
62 const [audioOutputDeviceId, setAudioOutputDeviceId] = useState<string>();
63 const [videoDeviceId, setVideoDeviceId] = useState<string>();
64
65 const getMediaDevices = useCallback(async (): Promise<MediaDevicesInfo> => {
66 try {
67 const devices = await navigator.mediaDevices.enumerateDevices();
68
69 // TODO: On Firefox, some devices can sometime be duplicated (2 devices can share the same deviceId). Using a map
70 // and then converting it to an array makes it so that there is no duplicate. If we find a way to prevent
71 // Firefox from listing 2 devices with the same deviceId, we can remove this logic.
72 const newMediaDevices: Record<MediaDeviceKind, Record<string, MediaDeviceInfo>> = {
73 audioinput: {},
74 audiooutput: {},
75 videoinput: {},
76 };
77
78 for (const device of devices) {
79 newMediaDevices[device.kind][device.deviceId] = device;
80 }
81
82 return {
83 audioinput: Object.values(newMediaDevices.audioinput),
84 audiooutput: Object.values(newMediaDevices.audiooutput),
85 videoinput: Object.values(newMediaDevices.videoinput),
86 };
87 } catch (e) {
88 throw new Error('Could not get media devices', { cause: e });
89 }
90 }, []);
91
92 const updateLocalStream = useCallback(
93 async (mediaDeviceIds?: MediaInputIds) => {
94 const devices = await getMediaDevices();
95
96 let audioConstraint: MediaTrackConstraints | boolean = devices.audioinput.length !== 0;
97 let videoConstraint: MediaTrackConstraints | boolean = devices.videoinput.length !== 0;
98
99 if (!audioConstraint && !videoConstraint) {
100 return;
101 }
102
103 if (mediaDeviceIds?.audio !== undefined) {
104 audioConstraint = mediaDeviceIds.audio !== false ? { deviceId: mediaDeviceIds.audio } : false;
105 }
106 if (mediaDeviceIds?.video !== undefined) {
107 videoConstraint = mediaDeviceIds.video !== false ? { deviceId: mediaDeviceIds.video } : false;
108 }
109
110 try {
111 const stream = await navigator.mediaDevices.getUserMedia({
112 audio: audioConstraint,
113 video: videoConstraint,
114 });
115
116 for (const track of stream.getTracks()) {
117 track.enabled = false;
118 }
119
120 setLocalStream(stream);
121 return stream;
122 } catch (e) {
123 throw new Error('Could not get media devices', { cause: e });
124 }
125 },
126 [getMediaDevices]
127 );
128
129 const currentMediaDeviceIds: CurrentMediaDeviceIds = useMemo(() => {
130 const createSetIdForDeviceKind = (mediaInputKind: MediaInputKind) => async (id: string | undefined) => {
131 const mediaDeviceIds = {
132 audio: audioInputDeviceId,
133 video: videoDeviceId,
134 };
135
136 mediaDeviceIds[mediaInputKind] = id;
137
138 await updateLocalStream(mediaDeviceIds);
139 };
140
141 return {
142 audioinput: {
143 id: audioInputDeviceId,
144 setId: createSetIdForDeviceKind('audio'),
145 },
146 audiooutput: {
147 id: audioOutputDeviceId,
148 setId: setAudioOutputDeviceId,
149 },
150 videoinput: {
151 id: videoDeviceId,
152 setId: createSetIdForDeviceKind('video'),
153 },
154 };
155 }, [updateLocalStream, audioInputDeviceId, audioOutputDeviceId, videoDeviceId]);
156
157 const updateScreenShare = useCallback(
158 async (isOn: boolean) => {
159 if (isOn) {
160 const stream = await navigator.mediaDevices.getDisplayMedia({
161 video: true,
162 audio: false,
163 });
164
165 setScreenShareLocalStream(stream);
166 return stream;
167 } else {
168 if (screenShareLocalStream) {
169 for (const track of screenShareLocalStream.getTracks()) {
170 track.stop();
171 }
172 }
173
174 setScreenShareLocalStream(undefined);
175 }
176 },
177 [screenShareLocalStream]
178 );
179
180 useEffect(() => {
181 const updateMediaDevices = async () => {
182 try {
183 const newMediaDevices = await getMediaDevices();
184
185 if (newMediaDevices.audiooutput.length !== 0 && !audioOutputDeviceId) {
186 setAudioOutputDeviceId(newMediaDevices.audiooutput[0].deviceId);
187 }
188
189 setMediaDevices(newMediaDevices);
190 } catch (e) {
191 console.error('Could not update media devices:', e);
192 }
193 };
194
195 navigator.mediaDevices.addEventListener('devicechange', updateMediaDevices);
196 updateMediaDevices();
197
198 return () => {
199 navigator.mediaDevices.removeEventListener('devicechange', updateMediaDevices);
200 };
201 }, [getMediaDevices, audioOutputDeviceId]);
202
203 const stopStream = useCallback((stream: MediaStream) => {
204 const localTracks = stream.getTracks();
205 if (localTracks) {
206 for (const track of localTracks) {
207 track.stop();
208 }
209 }
210 }, []);
211
212 const stopMedias = useCallback(() => {
213 if (localStream) {
214 stopStream(localStream);
215 }
216 if (screenShareLocalStream) {
217 stopStream(screenShareLocalStream);
218 }
219 }, [localStream, screenShareLocalStream, stopStream]);
220
221 const value = useMemo(
222 () => ({
223 localStream,
224 updateLocalStream,
225 screenShareLocalStream,
226 updateScreenShare,
227 mediaDevices,
228 currentMediaDeviceIds,
229 setAudioInputDeviceId,
230 setAudioOutputDeviceId,
231 setVideoDeviceId,
232 stopMedias,
233 }),
234 [
235 localStream,
236 updateLocalStream,
237 screenShareLocalStream,
238 updateScreenShare,
239 mediaDevices,
240 currentMediaDeviceIds,
241 setAudioInputDeviceId,
242 setAudioOutputDeviceId,
243 setVideoDeviceId,
244 stopMedias,
245 ]
246 );
247
248 return (
249 <optionalUserMediaContext.Context.Provider value={value}>{children}</optionalUserMediaContext.Context.Provider>
250 );
251};