Skip to content

Commit

Permalink
fix: catch errors in camera queue
Browse files Browse the repository at this point in the history
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
  • Loading branch information
gruhn committed May 23, 2024
1 parent f762513 commit 269beb6
Show file tree
Hide file tree
Showing 4 changed files with 94 additions and 74 deletions.
65 changes: 30 additions & 35 deletions docs/.vitepress/components/demos/FullDemo.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,20 @@
<p>
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:
</p>
For more fine-grained control, you can select a camera by device constraints or by the device
ID:

<p
class="error"
v-if="availableDevices === null"
>
No cameras on this device
<select v-model="selectedConstraints">
<option
v-for="option in constraintOptions"
:key="option.label"
:value="option.constraints"
>
{{ option.label }}
</option>
</select>
</p>

<select
v-model="selectedDevice"
v-else
>
<option
v-for="device in availableDevices"
:key="device.deviceId"
:value="device"
>
{{ device.label }} (ID: {{ device.deviceId }})
</option>
</select>

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

<div>
<qrcode-stream
:constraints="constraints"
:constraints="selectedConstraints"
:track="trackFunctionSelected.value"
:formats="selectedBarcodeFormats"
@error="onError"
Expand All @@ -95,27 +85,32 @@ function onDetect(detectedCodes) {
/*** select camera ***/
const selectedDevice = ref(null)
const availableDevices = ref(null)
const selectedConstraints = ref({ facingMode: 'environment' })
const defaultConstraintOptions = [
{ label: 'rear camera', constraints: { facingMode: 'environment' } },
{ label: 'front camera', constraints: { facingMode: 'user' } }
]
const constraintOptions = ref(defaultConstraintOptions)
async function onCameraReady() {
// NOTE: on iOS we can't invoke `enumerateDevices` before the user has given
// camera access permission. `QrcodeStream` internally takes care of
// requesting the permissions. The `camera-on` event should guarantee that this
// has happened.
availableDevices.value = (await navigator.mediaDevices.enumerateDevices()).filter(
({ kind }) => 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) {
Expand Down
2 changes: 1 addition & 1 deletion docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
97 changes: 59 additions & 38 deletions src/misc/camera.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<TaskResult> = Promise.resolve({ type: 'stop', data: {} })

Expand Down Expand Up @@ -132,50 +138,65 @@ export async function start(
}
): Promise<Partial<MediaTrackCapabilities>> {
// 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(
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/misc/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}

0 comments on commit 269beb6

Please sign in to comment.