From 269beb6892eed817c14a7dbc80476337ff6f1d48 Mon Sep 17 00:00:00 2001 From: Niklas Gruhn Date: Wed, 22 May 2024 21:16:33 +0200 Subject: [PATCH] fix: catch errors in camera queue If `runStartTask` manages to start a camera but then errors, the `taskQueue` is perpetually in an error state. Fixing that by catching the error. Closes #433 --- docs/.vitepress/components/demos/FullDemo.vue | 65 ++++++------- docs/.vitepress/config.ts | 2 +- src/misc/camera.ts | 97 +++++++++++-------- src/misc/util.ts | 4 + 4 files changed, 94 insertions(+), 74 deletions(-) diff --git a/docs/.vitepress/components/demos/FullDemo.vue b/docs/.vitepress/components/demos/FullDemo.vue index 370ad942..5c0319e0 100644 --- a/docs/.vitepress/components/demos/FullDemo.vue +++ b/docs/.vitepress/components/demos/FullDemo.vue @@ -3,30 +3,20 @@

Modern mobile phones often have a variety of different cameras installed (e.g. front, rear, wide-angle, infrared, desk-view). The one picked by default is sometimes not the best choice. - If you want fine-grained control, which camera is used, you can enumerate all installed - cameras and then pick the one you need based on it's device ID: -

+ For more fine-grained control, you can select a camera by device constraints or by the device + ID: -

- No cameras on this device +

- -

Detected codes are visually highlighted in real-time. Use the following dropdown to change the flavor: @@ -69,7 +59,7 @@

kind === 'videoinput' - ) + const devices = await navigator.mediaDevices.enumerateDevices() + const videoDevices = devices.filter(({ kind }) => kind === 'videoinput') + + constraintOptions.value = [ + ...defaultConstraintOptions, + ...videoDevices.map(({ deviceId, label }) => ({ + label: `${label} (ID: ${deviceId})`, + constraints: { deviceId } + })) + ] + + error.value = '' } -const constraints = computed(() => { - if (selectedDevice.value === null) { - return { facingMode: 'environment' } - } else { - return { deviceId: selectedDevice.value.deviceId } - } -}) - /*** track functons ***/ function paintOutline(detectedCodes, ctx) { diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 31ad0017..a31fe98d 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -4,7 +4,7 @@ import { withPwa } from '@vite-pwa/vitepress' if (process.env.VITEPRESS_BASE === undefined) { console.warn('env var VITEPRESS_BASE is undefined. Defaulting to: /vue-qrcode-reader/') } -const { VITEPRESS_BASE } = process.env ?? '/vue-qrcode-reader/' +const { VITEPRESS_BASE = '/vue-qrcode-reader/' } = process.env export default withPwa( defineConfig({ diff --git a/src/misc/camera.ts b/src/misc/camera.ts index 3d3dac7e..8ba1c7c8 100644 --- a/src/misc/camera.ts +++ b/src/misc/camera.ts @@ -1,8 +1,9 @@ import { StreamApiNotSupportedError, InsecureContextError, StreamLoadTimeoutError } from './errors' import { eventOn, timeout } from './callforth' import shimGetUserMedia from './shimGetUserMedia' +import { assertNever } from './util' -interface StartTaskResult { +type StartTaskResult = { type: 'start' data: { videoEl: HTMLVideoElement @@ -13,12 +14,17 @@ interface StartTaskResult { } } -interface StopTaskResult { +type StopTaskResult = { type: 'stop' data: {} } -type TaskResult = StartTaskResult | StopTaskResult +type FailedTask = { + type: 'failed' + error: Error +} + +type TaskResult = StartTaskResult | StopTaskResult | FailedTask let taskQueue: Promise = Promise.resolve({ type: 'stop', data: {} }) @@ -132,50 +138,65 @@ export async function start( } ): Promise> { // update the task queue synchronously - taskQueue = taskQueue.then((prevTaskResult) => { - if (prevTaskResult.type === 'start') { - // previous task is a start task - // we'll check if we can reuse the previous result - const { - data: { - videoEl: prevVideoEl, - stream: prevStream, - constraints: prevConstraints, - isTorchOn: prevIsTorchOn + taskQueue = taskQueue + .then((prevTaskResult) => { + if (prevTaskResult.type === 'start') { + // previous task is a start task + // we'll check if we can reuse the previous result + const { + data: { + videoEl: prevVideoEl, + stream: prevStream, + constraints: prevConstraints, + isTorchOn: prevIsTorchOn + } + } = prevTaskResult + // TODO: Should we keep this object comparison + // this code only checks object sameness not equality + // deep comparison requires snapshots and value by value check + // which seem too much + if ( + !restart && + videoEl === prevVideoEl && + constraints === prevConstraints && + torch === prevIsTorchOn + ) { + // things didn't change, reuse the previous result + return prevTaskResult } - } = prevTaskResult - // TODO: Should we keep this object comparison - // this code only checks object sameness not equality - // deep comparison requires snapshots and value by value check - // which seem too much - if ( - !restart && - videoEl === prevVideoEl && - constraints === prevConstraints && - torch === prevIsTorchOn - ) { - // things didn't change, reuse the previous result - return prevTaskResult + // something changed, restart (stop then start) + return runStopTask(prevVideoEl, prevStream, prevIsTorchOn).then(() => + runStartTask(videoEl, constraints, torch) + ) + } else if (prevTaskResult.type === 'stop' || prevTaskResult.type === 'failed') { + // previous task is a stop/error task + // we can safely start + return runStartTask(videoEl, constraints, torch) } - // something changed, restart (stop then start) - return runStopTask(prevVideoEl, prevStream, prevIsTorchOn).then(() => - runStartTask(videoEl, constraints, torch) - ) - } - // previous task is a stop task - // we can safely start - return runStartTask(videoEl, constraints, torch) - }) + + assertNever(prevTaskResult) + }) + .catch((error: Error) => { + console.debug(`[vue-qrcode-reader] starting camera failed with "${error}"`) + return { type: 'failed', error } + }) + // await the task queue asynchronously const taskResult = await taskQueue + if (taskResult.type === 'stop') { // we just synchronously updated the task above // to make the latest task a start task // so this case shouldn't happen throw new Error('Something went wrong with the camera task queue (start task).') + } else if (taskResult.type === 'failed') { + throw taskResult.error + } else if (taskResult.type === 'start') { + // return the data we want + return taskResult.data.capabilities } - // return the data we want - return taskResult.data.capabilities + + assertNever(taskResult) } async function runStopTask( @@ -208,7 +229,7 @@ async function runStopTask( export async function stop() { // update the task queue synchronously taskQueue = taskQueue.then((prevTaskResult) => { - if (prevTaskResult.type === 'stop') { + if (prevTaskResult.type === 'stop' || prevTaskResult.type === 'failed') { // previous task is a stop task // no need to stop again return prevTaskResult diff --git a/src/misc/util.ts b/src/misc/util.ts index 890dbed3..d2baf68f 100644 --- a/src/misc/util.ts +++ b/src/misc/util.ts @@ -45,3 +45,7 @@ export function assert(condition: boolean, failureMessage?: string): asserts con throw new Error(failureMessage ?? 'assertion failure') } } + +export function assertNever(_witness: never): never { + throw new Error('this code should be unreachable') +}