Writing a Native Like Web-Camera App

Adrian Jost
The Startup
Published in
5 min readDec 31, 2020

Before starting a new project, I always ask myself: why?
This time, it’s to show the capabilities of the web and some new cutting edge features.

What we will build. (Photo by Ben White on Unsplash)

The Concept

My idea is, that the camera app should behave similarly to the camera app natively installed on your phone. So it should definitely work offline. Photos should be saved in a directory directly on your phone, so any arbitrary gallery app can access it. It would also be nice if you could use the power of all the cameras included in a smartphone.

Breaking things up, I need:
- Some way to access all cameras and switch between them
- Access to the local file system to save photos
- Make it PWA ready and work offline

Let’s start building

Step 1 — Getting Camera Access

After a little bit of research, I stumbled over simpl.info. It’s an awesome site to get very simple demos of web APIs. And they had a great example of how to access all the cameras accessible by the browser.

Getting all the available cameras is as easy as:

await navigator.mediaDevices.getUserMedia({ video: true });const mediaDevices = await navigator.mediaDevices.enumerateDevices();const cameraDevices = mediaDevices.filter(
(device) => device.kind === "videoinput"
);

Each of these cameras has a unique deviceId and eventually a label attribute. We can use those, to let the user select their camera. Unfortunately, there is no reliable way to detect the direction the camera is facing, but maybe this capability will be added in the future. At least the labels are relatively descriptive.

The first line is needed because some browsers will not give you the deviceId unless you already have permission to access the camera, but to open the prompt to get access you must request it first. I experienced this in Chrome and Safari.

Step 2 — Showing the Camera Feed

With the deviceId of the camera we just got, we can open a video stream and set this stream as the source of a video element. Et voila, we have a live camera preview.

yep, that's me

and here is the required code:

const newCameraDeviceId = cameraDevices[0];
const activeVideoStream = await navigator.mediaDevices.getUserMedia({
video: { deviceId: { exact: newCameraDeviceId } }
});
document.querySelector(video#preview).srcObject = activeVideoStream

But we need to be careful to stop the activeVideoStream before we show the feed of the new camera otherwise some devices may refuse to give you the feed of the same camera again. In general, you should only have at most one active stream per device.

if (activeVideoStream) {
await Promise.all(
activeVideoStream
.getTracks()
.map((track) => track.stop())
);
}

Step 3 — Taking Photos

The next step is to take a photo. To do so, we need to create an ImageCapture instance based on the current camera stream and take a photo. This image blob can then be saved to disc or converted into an URL to show it in a regular HTML img element.

function takePhoto(){
const cameraVideoTrack = activeVideoStream.getVideoTracks()[0];
const imageCapture = new ImageCapture(cameraVideoTrack);
const imageBlob = await imageCapture.takePhoto({
fillLightMode: "auto", // "off", "flash"
redEyeReduction: true,
});
await saveCapture(imageBlob); // show image in an img tag
const imageUrl = URL.createObjectURL(imageBlob);
document.querySelector(“img#newest-capture”).src = imageUrl;
}
I used the img blob to show a little preview in the bottom right corner.

Step 4 — Saving Photos

To save the photo, it would be a bad experience if you have to choose the directory so save to every time you take a photo. It would also be suboptimal if it would always save to the regular download directory. Native camera apps usually save into a fixed image directory. To mimic this behavior, we can use the new File System Access API which allows granting websites access to a directory, and they can do whatever they want within this directory.

This means we need to get access to a photo directory the user chooses once and then we can save as many photos as we want into it.

async function saveCapture(imageBlob){
const dirHandle = await showDirectoryPicker();
const fileHandle = await dirHandle.getFileHandle(
"new-awesome-photo.png",
{ create: true },
);
const writable = await fileHandle.createWritable();
await writable.write(imageBlob);
await writable.close();
}

Unfortunately this new API currently only works on desktop versions of Chrome, but hopefully, this will change soon.

Step 5 — Make it work offline

To make the application work offline and thus load way faster, we will need to write a service-worker. It’s basically a proxy between your website and the network. We can use this, to cache all files required for our page and deliver them from the cache whenever possible.

First we need to add some HTML to our page to reference and load the service-worker:

<!-- index.html -->
<script>
if ("serviceWorker" in navigator) {
window.addEventListener("load", function () {
navigator.serviceWorker.register("/service-worker.js");
});
}
</script>

After that, we can write a small service-worker that dynamically caches all the files of the webpage if they are accessed the first time and after that only from the cache. It’s such a simple use-case that I was able to just copy over the example from the google developers blog.

// service-worker.js
const CACHE_NAME = "pwcamera-v1.0.0";
self.addEventListener("fetch", function (event) {
event.respondWith(
caches.open(CACHE_NAME).then(function (cache) {
return cache.match(event.request).then(function (response) {
return (
response ||
fetch(event.request).then(function (response) {
cache.put(event.request, response.clone());
return response;
}));});}));});

I extended this example with a small cleanup code, that purges the cache if you change the cache name to push out an update.

self.addEventListener("activate", function (event) {
event.waitUntil(
caches.keys().then(function (cacheNames) {
return Promise.all(
cacheNames.map(function (cacheName) {
if (CACHE_NAME !== cacheName) {
return caches.delete(cacheName);
}}));}));});

From now on, we just need to remember to increment the cache-name version whenever we release an update. Otherwise the users will only see their cached version.

Step 6 — Make it installable (aka a true PWA)

The last step of our journey is, to add a web manifest to our site, so the browser will show a banner to install the webapp on your devices homescreen and open it without all the browser UI.

I used https://app-manifest.firebaseapp.com/ to create this cause I am lazy. But you can easily do this on your own. Unfortunately, the site's awesome icon resize feature is broken. The result will look like this:

// manifest.json
{
"name": "PWCamera",
"short_name": "PWCamera",
"theme_color": "#222244",
"background_color": "#222244",
"display": "fullscreen",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "icons/icon_192.png",
"sizes": "192x192",
"type": "image/png"
}
]
}

But don’t forget to link this in the index.html 😉

<link rel="manifest" href="manifest.json" />

And we are done. 🎉 You can test the live demo on pwcamera.adrianjost.dev or check out the full source code on GitHub. Happy Coding 🤓

If you want to try the File System Access API yourself, think about writing a gallery app that shows all the images in a given directory. This would be a perfect extension of this camera app to show all the photos taken.

--

--