Change devices
This tutorial guides you through the process of changing the microphone and camera devices in your app. It covers essential steps such as detecting available media devices, requesting user permissions, and dynamically switching between different microphones and cameras using the MediaDevices API.
You can download the starter code from Step.04-Exercise-Change-devices.
Settings interface
We will start by defining an interface that we will use to store the current
device selection. Create a new file in src/types/Settings.ts
with the
following content:
import {type MediaDeviceInfoLike} from '@pexip/media-control';
export interface Settings {
audioInput: MediaDeviceInfoLike | undefined;
audioOutput: MediaDeviceInfoLike | undefined;
videoInput: MediaDeviceInfoLike | undefined;
}
This interface will store the selected microphone (audioInput), camera (videoInput) and the speaker (audioOutput).
For changing the speaker we will use the sinkId
, however it's not implemented
in all browsers. For a compatibility list, you can check the
MDN Web Docs.
LocalStorageKey interface
We will also define an enum to store the keys used to save the selected devices in the local storage.
Create a new file in src/types/LocalStorageKey.ts
with the following content:
const prefix = 'pexip_';
export enum LocalStorageKey {
AudioInput = `${prefix}audio_input`,
AudioOutput = `${prefix}audio_output`,
VideoInput = `${prefix}video_input`,
}
App component
To implement this feature, we will start by editing the file src/App.tsx
.
We start by importing the new necessary types:
import {type MediaDeviceInfoLike} from '@pexip/media-control';
import {type Settings} from './types/Settings';
import {LocalStorageKey} from './types/LocalStorageKey';
Add the following state variables to the App
component:
const [devices, setDevices] = useState<MediaDeviceInfoLike[]>([]);
const [audioInput, setAudioInput] = useState<MediaDeviceInfoLike>();
const [audioOutput, setAudioOutput] = useState<MediaDeviceInfoLike>();
const [videoInput, setVideoInput] = useState<MediaDeviceInfoLike>();
These state variables will be used for the following purposes:
devices
will store the list of available media devices (microphones, cameras and speakers).audioInput
,audioOutput
, andvideoInput
will store the currently selected devices.
Now we will call the refreshDevices
function (we will define it later) to get
the available audioInput
and videoInput
devices. This function will take
into account the devices stored in the local storage and also the devices
currently available in the browser.
const handleStartConference = async (
nodeDomain: string,
conferenceAlias: string,
displayName: string
): Promise<void> => {
setNodeDomain(nodeDomain)
setConferenceAlias(conferenceAlias)
setDisplayName(displayName)
setConnectionState(ConnectionState.Connecting)
const { audioInput, videoInput } = await refreshDevices()
...
}
We will make a small change to the handleStartConference
, handleAudioMute
and handleVideoMute
functions. Previously, we only requested audio
({ audio: true }
) or video ({ video: true }
) will pass a new constrain to
getUserMedia
with the deviceId
of the selected device:
const localAudioStream = await navigator.mediaDevices.getUserMedia({
audio: {deviceId: audioInput?.deviceId},
});
const localVideoStream = await navigator.mediaDevices.getUserMedia({
video: {deviceId: videoInput?.deviceId},
});
Next, we will add a new function handleSettingsChange
that will receive the
new settings and update the audio and video streams accordingly.
This function will be responsible of:
- Stopping the current audio and video streams.
- Requesting the new audio and video streams.
- Sending the new audio and video streams to Pexip Infinity.
- Changing the speaker if the audio output has changed.
Here is the implementation of the handleSettingsChange
function:
const handleSettingsChange = async (settings: Settings): Promise<void> => {
let newAudioStream: MediaStream | null = null;
let newVideoStream: MediaStream | null = null;
// Get the new audio stream if the audio input has changed
if (settings.audioInput !== audioInput) {
localAudioStream?.getTracks().forEach(track => {
track.stop();
});
setAudioInput(settings.audioInput);
localStorage.setItem(
LocalStorageKey.AudioInput,
JSON.stringify(settings.audioInput),
);
newAudioStream = await navigator.mediaDevices.getUserMedia({
audio: {deviceId: settings.audioInput?.deviceId},
});
setLocalAudioStream(newAudioStream);
}
// Get the new video stream if the video input has changed
if (settings.videoInput !== videoInput) {
localVideoStream?.getTracks().forEach(track => {
track.stop();
});
setVideoInput(settings.videoInput);
localStorage.setItem(
LocalStorageKey.VideoInput,
JSON.stringify(settings.videoInput),
);
newVideoStream = await navigator.mediaDevices.getUserMedia({
video: {deviceId: settings.videoInput?.deviceId},
});
setLocalVideoStream(newVideoStream);
}
// Send the new audio and video stream to Pexip Infinity
if (newAudioStream != null || newVideoStream != null) {
infinityClient.setStream(
new MediaStream([
...(newAudioStream?.getTracks() ?? localAudioStream?.getTracks() ?? []),
...(newVideoStream?.getTracks() ?? localVideoStream?.getTracks() ?? []),
]),
);
}
// Change the speaker if the audio output has changed
if (settings.audioOutput !== audioOutput) {
setAudioOutput(settings.audioOutput);
localStorage.setItem(
LocalStorageKey.AudioOutput,
JSON.stringify(settings.audioOutput),
);
}
};
Now is time to define the refreshDevices
function that we used before. This
function will request the list of available media devices and update the
devices
list and the selected devices:
const refreshDevices = async (): Promise<Settings> => {
const devices = await navigator.mediaDevices.enumerateDevices();
setDevices(devices);
const audioInput = getMediaDeviceInfo(devices, 'audioinput');
setAudioInput(audioInput);
const audioOutput = getMediaDeviceInfo(devices, 'audiooutput');
setAudioOutput(audioOutput);
const videoInput = getMediaDeviceInfo(devices, 'videoinput');
setVideoInput(videoInput);
return {
audioInput,
audioOutput,
videoInput,
effect,
};
};
We will make a small change to the useEffect
hook. When the component is
mounted, we will subscribe to the event devicechange
which is triggered each
time the user connects or disconnects a device. In case of a change, we will
call the refreshDevices
function to update the list of devices:
useEffect(() => {
...
const handleDeviceChange = (): void => {
refreshDevices().catch(console.error)
}
window.addEventListener('beforeunload', disconnectBrowserClosed)
window.addEventListener('devicechange', handleDeviceChange)
return () => {
window.removeEventListener('beforeunload', disconnectBrowserClosed)
window.removeEventListener('devicechange', handleDeviceChange)
}
}, [])
Finally, we will pass the devices
, settings
and onSettingsChange
props to
the Conference
component:
<Conference
localVideoStream={localVideoStream}
remoteStream={remoteStream}
devices={devices}
settings={{
audioInput,
audioOutput,
videoInput
}}
onAudioMute={handleAudioMute}
onVideoMute={handleVideoMute}
onSettingsChange={handleSettingsChange}
onDisconnect={handleDisconnect}
/>
Conference component
Next, we will modify the src/components/Conference/Conference.tsx
to include
the new settings.
We start by importing all the libraries and types we will use:
import {useState} from 'react';
import {type MediaDeviceInfoLike} from '@pexip/media-control';
import {type Settings} from '../../types/Settings';
import {SettingsModal} from './SettingsModal/SettingsModal';
Add devices
, settings
and onSettingsChange
to the ConferenceProps
interface:
interface ConferenceProps {
localVideoStream: MediaStream | undefined;
remoteStream: MediaStream | undefined;
devices: MediaDeviceInfoLike[];
settings: Settings;
onAudioMute: (mute: boolean) => Promise<void>;
onVideoMute: (mute: boolean) => Promise<void>;
onSettingsChange: (settings: Settings) => Promise<void>;
onDisconnect: () => Promise<void>;
}
Inside the Conference
component, we will add a new state variable
settingsOpened
to control the visibility of the settings modal:
const [settingsOpened, setSettingsOpened] = useState(false);
Add settingsOpened
and onSettingsChange
to the Toolbar
component props:
<Toolbar
className="toolbar"
settingsOpened={settingsOpened}
onAudioMute={props.onAudioMute}
onVideoMute={props.onVideoMute}
onOpenSettings={() => {
setSettingsOpened(true)
}}
onDisconnect={props.onDisconnect}
/>
And we will create a instance of the SettingsModal
component, just under the
Toolbar
that will display a modal dialog to change the devices:
<SettingsModal
isOpen={settingsOpened}
onClose={() => {
setSettingsOpened(false)
}}
devices={props.devices}
settings={props.settings}
onSettingsChange={props.onSettingsChange}
/>
Finally, we will define the getMediaDeviceInfo
function that will return the
device stored in the local storage if it is available, otherwise it will return
the first device of the specified kind:
const getMediaDeviceInfo = (
devices: MediaDeviceInfoLike[],
kind: MediaDeviceKind,
): MediaDeviceInfoLike | undefined => {
let storageKey: string;
switch (kind) {
case 'audioinput': {
storageKey = LocalStorageKey.AudioInput;
break;
}
case 'audiooutput': {
storageKey = LocalStorageKey.AudioOutput;
break;
}
case 'videoinput': {
storageKey = LocalStorageKey.VideoInput;
break;
}
}
const mediaDeviceStored = localStorage.getItem(storageKey);
let mediaDeviceInfo: MediaDeviceInfoLike | undefined;
if (mediaDeviceStored != null) {
mediaDeviceInfo = JSON.parse(mediaDeviceStored) as MediaDeviceInfo;
const found = devices.some(
device => device.deviceId === mediaDeviceInfo?.deviceId,
);
if (!found) {
mediaDeviceInfo = devices.find(device => device.kind === kind);
}
} else {
mediaDeviceInfo = devices.find(device => device.kind === kind);
}
return mediaDeviceInfo;
};
Toolbar component
Now we will add a new button in the
src/components/Conference/Toolbar/Toolbar.tsx
file to open the settings modal.
Start by adding settingsOpened
and onOpenSettings
to the ToolbarProps
interface:
interface ToolbarProps {
className: string;
settingsOpened: boolean;
onAudioMute: (mute: boolean) => Promise<void>;
onVideoMute: (mute: boolean) => Promise<void>;
onOpenSettings: () => void;
onDisconnect: () => Promise<void>;
}
And finally, add the new button to open the settings modal:
<Tooltip text={'Settings'}>
<Button
onClick={() => {
props.onOpenSettings()
}}
variant="translucent"
modifier="square"
isActive={!props.settingsOpened}
colorScheme="light"
>
<Icon source={IconTypes.IconSettings} />
</Button>
</Tooltip>
SettingsModal component
Finally, we will create a new component in the
src/components/Conference/SettingsModal/SettingsModal.tsx
file. This component
will display a modal dialog to change the devices when the user clicks on the
settings button in the toolbar.
We will start by defining the properties:
interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
devices: MediaDeviceInfoLike[];
settings: Settings;
onSettingsChange: (settings: Settings) => Promise<void>;
}
It receives the following properties:
isOpen
: A boolean to control the visibility of the modal.onClose
: A function that will be called when the modal is closed.devices
: A list of available media devices.settings
: The current selected devices.onSettingsChange
: A function that will be called when the devices are changed.
And we will include this properties in the function signature:
export const SettingsModal = (props: SettingsModalProps): JSX.Element => {
...
}
Now we go inside the SettingsModal
functional component and implement the
logic to change the devices.
We will start by defining the state variables to store the local video stream and the selected devices:
const [localStream, setLocalStream] = useState<MediaStream>();
const [audioInput, setAudioInput] = useState<MediaDeviceInfoLike>();
const [audioOutput, setAudioOutput] = useState<MediaDeviceInfoLike>();
const [videoInput, setVideoInput] = useState<MediaDeviceInfoLike>();
We will create a function called handleVideoInputChange
that will request the
new video stream when the video input device is changed:
const handleVideoInputChange = (device: MediaDeviceInfoLike): void => {
if (device.deviceId !== videoInput?.deviceId) {
setVideoInput(device);
if (localStream != null) {
localStream.getVideoTracks().forEach(track => {
track.stop();
});
navigator.mediaDevices
.getUserMedia({
video: {deviceId: device.deviceId},
})
.then(stream => {
setLocalStream(stream);
})
.catch(console.error);
}
}
};
We will create a function handleSave
that will save the new settings and close
the modal:
const handleSave = (): void => {
props
.onSettingsChange({
audioInput,
audioOutput,
videoInput,
})
.catch(console.error);
props.onClose();
};
We will use the useEffect
hook to request the local video stream when the
modal is opened and stop the stream when the modal is closed:
useEffect(() => {
const bootstrap = async (): Promise<void> => {
if (props.isOpen) {
setAudioInput(props.settings.audioInput);
setAudioOutput(props.settings.audioOutput);
setVideoInput(props.settings.videoInput);
const localStream = await navigator.mediaDevices.getUserMedia({
video: {deviceId: props.settings.videoInput?.deviceId},
});
setLocalStream(localStream);
} else {
localStream?.getTracks().forEach(track => {
track.stop();
});
setLocalStream(undefined);
}
};
bootstrap().catch(console.error);
}, [props.isOpen]);
Finally, we render the SettingsModal
component:
return (
<Modal
isOpen={props.isOpen}
onClose={props.onClose}
className="SettingsModal"
withCloseButton={true}
>
<h3 className="Title">Settings</h3>
<Video srcObject={localStream} isMirrored={true} />
<div className="DeviceSelectionContainer">
<h4>Select devices</h4>
<DevicesSelection
devices={props.devices}
audioInput={audioInput}
audioOutput={audioOutput}
videoInput={videoInput}
onAudioInputChange={setAudioInput}
onAudioOutputChange={setAudioOutput}
onVideoInputChange={handleVideoInputChange}
setShowHelpVideo={() => {}}
videoInputError={{
title: '',
description: undefined,
deniedDevice: undefined
}}
audioInputError={{
title: '',
description: undefined,
deniedDevice: undefined
}}
/>
</div>
<div className="ButtonSet">
<Button variant="secondary" onClick={props.onClose}>
Cancel
</Button>
<Button onClick={handleSave}>Save</Button>
</div>
</Modal>
)
We rendered the following components:
Video
component to display the local video stream.DevicesSelection
component to select the audio and video devices.Button
components to save or cancel the changes.
And that's it! You have successfully implemented the ability to change the microphone and camera devices in your app.
Run the app
When you run the app, you will see a new button in the interface. If you click on it, you will see a new modal dialog to select the camera, microphone and speaker. If you change the camera, you will see that the local video will be updated with the new camera.
Once the changes are saved, the new devices will be used in the conference.
You can compare your code with the solution in Step.04-Solution-Change devices. You can also check the differences with the previous lesson in the git diff.