loading...
Cover image for Vue.js Composition API: usage with MediaDevices API

Vue.js Composition API: usage with MediaDevices API

3vilarthas profile image Andrew ・5 min read

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 the deviceId 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 pushes 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.

Posted on by:

Discussion

markdown guide