Introduction
In this article I would like to share my experience about how Vue Composition API helped me to organise and structure work with browser's navigator.mediaDevices
API.
It's highly encouraged to skim through the RFC of the upcoming Composition API before reading.
Task
The task I received was not trivial:
- application should display all the connected cameras, microphones and speakers that user have;
- user should have the ability to switch between them (e.g. if user have two cameras he/she can choose which one is active);
- application should appropriately react when user connects or disconnects devices;
- the solution should be easily reusable, so developers can use it on any page.
Solution
For now, the only one way to reuse logic across components was mixins
. But they have their own nasty drawbacks, so I decided to give a chance to a new Composition API.
Vue Composition API allows easily share stateful logic across components.
Let's start with separation of concerns – create three appropriate hooks useCamera
, useMicrophone
, useSpeaker
. Each hook encapsulates the logic related to the specific device kind.
Let's look at one of them — useCamera
:
useCamera.ts
:
import { ref, onMounted, onUnmounted } from '@vue/composition-api'
export function useCamera() {
const camera = ref('')
const cameras = ref<MediaDeviceInfo[]>([])
function handler() {
navigator.mediaDevices.enumerateDevices().then(devices => {
const value = devices.filter(device => device.kind === 'videoinput')
cameras.value = value
if (cameras.value.length > 0) {
camera.value = cameras.value[0].deviceId
}
})
}
onMounted(() => {
if (navigator && navigator.mediaDevices) {
navigator.mediaDevices.addEventListener('devicechange', handler)
handler()
}
})
onUnmounted(() => {
if (navigator && navigator.mediaDevices) {
navigator.mediaDevices.removeEventListener('devicechange', handler)
}
})
return {
camera,
cameras,
}
}
Here is some explanations:
First off create two variables:
-
camera
, which will store thedeviceId
of the active camera (remember that user can choose active device); -
cameras
, which will contain the list of all connected cameras.
These variables is supposed to be consumed by the component, so we return them.
There is handler
function which enumerates all the connected devices and push
es only those with kind === 'videoinput'
to the cameras
array. The type of cameras
variable is MediaDeviceInfo[]
, here is the snippet from lib.dom.d.ts
which declares that interface:
type MediaDeviceKind = "audioinput" | "audiooutput" | "videoinput";
/** The MediaDevicesInfo interface contains information that describes a single media input or output device. */
interface MediaDeviceInfo {
readonly deviceId: string;
readonly groupId: string;
readonly kind: MediaDeviceKind;
readonly label: string;
toJSON(): any;
}
Composition API provides us with onMounted
and onUnmounted
hooks, which is the analog to the current Options API mounted
and destroyed
hooks. As you can see, we invoke our handler
function in onMounted
hook to get the list of cameras, when component mounts.
Since devices can be connected or disconnected during the runtime of the application, we have to synchronize our data model with actually connected devices. To accomplish that task we need to subscribe to devicechange
event which fires either when new device connects or already connected device disconnects. Since we did subscription, we need to not forget to unsubscribe from this event when component is completely destroyed to not catch any nasty bugs.
We have all set up, now let's use our custom hook in a component.
component.vue
:
<script lang="ts">
import { createComponent, computed, watch } from '@vue/composition-api'
import { useCamera } from '@/use/camera'
export default createComponent({
name: 'MyComponent',
setup() {
const { camera, cameras } = useCamera()
const camerasLabels = computed(() =>
cameras.value.map(camera => camera.label || camera.deviceId)
)
watch(cameras, value => {
console.log(value)
})
return {
camerasLabels,
}
},
})
</script>
<template>
<section>Connected cameras: {{ camerasLabels }}</section>
</template>
Our hook can only be used during the invocation of a setup
hook. When hook is invoked it returns our two variables: camera
and cameras
.
From that moment we can do whatever we want – we have fully reactive variables, as we would have with data
using Options API.
For example, let's create a computed property camerasLabels
which will list labels of cameras
.
Note that when new camera connects or already connected camera disconnects our hook will handle it and update cameras
value, which itself is reactive, so our template will be updated too. We can even watch for cameras
and perform our custom logic.
The code of useMicrophone
and useSpeaker
code is the same, but the only difference is device.kind
in the handler
function. Thus, the solution can be reduced into the one hook – useDevice
, which can accept the device kind as it's first argument:
export function useDevice(kind: MediaDeviceKind) {
// ... the same logic
function handler() {
navigator.mediaDevices.enumerateDevices().then(devices => {
const value = devices.filter(device => device.kind === kind) // <- filter by device kind
// ... the same logic
})
}
// ... the same logic
}
But I would prefer to split it up using three different hooks, because there might be logic specific to the device kind.
So our final solution looks something like this:
<script lang="ts">
import { createComponent, computed, watch } from '@vue/composition-api'
import { useCamera } from '../use/camera'
import { useMicrophone } from '../use/microphone'
import { useSpeaker } from '../use/speaker'
export default createComponent({
name: 'App',
setup() {
const { camera, cameras } = useCamera()
const { microphone, microphones } = useMicrophone()
const { speaker, speakers } = useSpeaker()
// computed
const camerasLabels = computed(() =>
cameras.value.map(camera => camera.label)
)
// or method
function getDevicesLabels(devices: MediaDeviceInfo[]) {
return devices.map(device => device.label)
}
watch(cameras, value => {
console.log(value)
})
return {
camerasLabels,
microphones,
speakers,
getDevicesLabels,
}
},
})
</script>
<template>
<ul>
<li>Connected cameras: {{ camerasLabels }}</li>
<li>Connected microphones: {{ getDevicesLabels(microphones) }}</li>
<li>Connected speakers: {{ getDevicesLabels(speakers) }}</li>
</ul>
</template>
Demo
Live demo is located here. You can experiment with it a little – connect a new microphone or camera and you will see how the application reacts.
I have cheated a little. As you can see there are some lines:
await navigator.mediaDevices.getUserMedia({ video: true }) // <- in useCamera
await navigator.mediaDevices.getUserMedia({ audio: true }) // <- in useMicrophone and useSpeaker
It ensures that user have granted access to camera and microphone. If user have denied access to devices hooks won't work. So they implies that user have granted access to devices.
Conclusion
We have created a bunch of useful hooks, that can be easily shared across projects to facilitate work with navigator.mediaDevices
. Our hooks react to the actual devices state and synchronize it with data model. The API is simple enough – just execute hook in the setup
method, all the logic is encapsulated in hook itself.
P.S. If you like the article, please, click "heart" or "unicorn" — it will give me some motivation to write the next article, where I plan to showcase how to combine these hooks in the useMediaStream
hook, which contains stream with our active camera and microphone. Article also will describe how to change input and output sources of the stream.
Top comments (1)
I guess you never got around to writing the post about the useMediaStream hook?