Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Cannot switch between front and back camera #633

Merged
merged 14 commits into from
Jul 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@
"@klayr/validator": "^0.8.2",
"@mdi/font": "^7.4.47",
"@stablelib/utf8": "^1.0.2",
"@zxing/browser": "^0.1.4",
"@zxing/library": "^0.20.0",
"@zxing/browser": "^0.1.5",
"@zxing/library": "^0.21.0",
"assert": "^2.1.0",
"axios": "^1.6.8",
"b64-to-blob": "^1.2.19",
Expand Down
177 changes: 103 additions & 74 deletions src/components/QrcodeScannerDialog.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<v-dialog v-model="show" :class="className" width="500">
<v-card :class="className">
<v-dialog v-model="show" :class="classes.root" width="500">
<v-card :class="classes.root">
<!-- Camera Waiting -->
<v-row
v-if="cameraStatus === 'waiting'"
Expand All @@ -18,19 +18,20 @@
<!-- Camera Active -->
<v-row v-show="cameraStatus === 'active'" no-gutters>
<v-col cols="12">
<div :class="`${className}__camera`">
<video ref="camera" />
<v-menu v-if="cameras.length > 1" offset-y :class="`${className}__camera-select`">
<template #activator>
<v-btn variant="text" color="white">
<div :class="classes.camera">
<video ref="videoElement" />
<v-menu v-if="cameras.length > 1" offset-y :class="classes.cameraSelect">
<template #activator="{ props }">
<v-btn variant="text" color="white" v-bind="props">
<v-icon size="x-large" icon="mdi-camera" />
</v-btn>
</template>
<v-list>
<v-list-item
v-for="camera in cameras"
:key="camera.deviceId"
@click="currentCamera = camera.deviceId"
v-for="(camera, index) in cameras"
:key="index"
@click="currentCamera = index"
:disabled="currentCamera === index"
>
<v-list-item-title>{{ camera.label }}</v-list-item-title>
</v-list-item>
Expand Down Expand Up @@ -75,92 +76,120 @@
</v-dialog>
</template>

<script>
<script lang="ts">
import { computed, defineComponent, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useStore } from 'vuex'
import type { IScannerControls } from '@zxing/browser'

import { Scanner } from '@/lib/zxing'

export default {
const className = 'qrcode-scanner-dialog'
const classes = {
root: className,
camera: `${className}__camera`,
cameraSelect: `${className}__camera-select`
}

type CameraStatus = 'waiting' | 'active' | 'nocamera'

export default defineComponent({
props: {
modelValue: {
type: Boolean,
required: true
}
},
emits: ['scan', 'update:modelValue'],
data: () => ({
cameraStatus: 'waiting', // can be: waiting, active, nocamera
scanner: null,
currentCamera: null,
cameras: []
}),
computed: {
className: () => 'qrcode-scanner-dialog',
show: {
setup(props, { emit }) {
const store = useStore()
const { t } = useI18n()

const videoElement = ref<HTMLVideoElement | null>(null)
const cameraStatus = ref<CameraStatus>('waiting')
const scanner = ref<Scanner | null>(null)
const cameras = ref<MediaDeviceInfo[]>([])
const currentCamera = ref<number | null>(null)
const scannerControls = ref<IScannerControls | null>(null)

const show = computed<boolean>({
get() {
return this.modelValue
return props.modelValue
},
set(value) {
this.$emit('update:modelValue', value)
emit('update:modelValue', value)
}
})

const init = async () => {
try {
scanner.value = new Scanner({
videoElement: videoElement.value!
})

await scanner.value.init()
cameras.value = await scanner.value.getCameras()
} catch (error) {
cameraStatus.value = 'nocamera'
store.dispatch('snackbar/show', {
message: t('scan.something_wrong')
})
console.error(error)
}
}
},
watch: {
cameras(cameras) {

const destroyScanner = () => {
if (!scannerControls.value) return
return scanner.value?.stop(scannerControls.value)
}

const onScan = (content: string) => {
emit('scan', content)
destroyScanner()
show.value = false
}

watch(cameras, (cameras) => {
if (cameras.length > 0) {
const cameraKey = cameras.length === 2 ? 1 : 0
this.currentCamera = this.cameras[cameraKey].deviceId
currentCamera.value = cameras.length >= 2 ? 1 : 0

this.cameraStatus = 'active'
cameraStatus.value = 'active'
} else {
this.cameraStatus = 'nocamera'
cameraStatus.value = 'nocamera'
}
},
currentCamera() {
this.scanner.start(this.currentCamera).then((content) => this.onScan(content))
}
},
mounted() {
this.init()
},
beforeUnmount() {
this.destroyScanner()
},
methods: {
init() {
return this.initScanner()
.then(() => {
return this.getCameras()
})
.catch((err) => {
this.cameraStatus = 'nocamera'
this.$store.dispatch('snackbar/show', {
message: this.$t('scan.something_wrong')
})
console.error(err)
})
},
async initScanner() {
this.scanner = new Scanner({
videoElement: this.$refs.camera
})

watch(currentCamera, () => {
void scanner.value?.start(currentCamera.value, (result, _, controls) => {
if (result) {
onScan(result.getText()) // text is private field for zxing/browser
}

if (controls) {
scannerControls.value = controls
}
})
})

return this.scanner.init()
},
destroyScanner() {
// First check if the scanner was initialized.
// Needed when an unexpected error occurred,
// or when the dialog closes before initialization.
return this.scanner && this.scanner.stop()
},
async getCameras() {
this.cameras = await this.scanner.getCameras()
},
onScan(content) {
this.$emit('scan', content)
this.destroyScanner()
this.show = false
onMounted(() => {
init()
})

onBeforeUnmount(() => {
destroyScanner()
})

return {
cameras,
cameraStatus,
classes,
currentCamera,
props,
show,
videoElement
}
}
}
})
</script>

<style lang="scss" scoped>
Expand Down
30 changes: 0 additions & 30 deletions src/lib/zxing/index.js

This file was deleted.

76 changes: 76 additions & 0 deletions src/lib/zxing/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { BrowserQRCodeReader, IScannerControls } from '@zxing/browser'
import type { Exception, Result } from '@zxing/library'

type DecodeContinuouslyCallback = (
result?: Result,
error?: Exception,
controls?: IScannerControls
) => void
export class Scanner {
cameraStream!: MediaStream
codeReader!: BrowserQRCodeReader
videoElement: HTMLVideoElement

constructor({ videoElement }: { videoElement: HTMLVideoElement }) {
this.videoElement = videoElement
}

async init() {
const { BrowserQRCodeReader } = await import('@zxing/browser')
this.codeReader = new BrowserQRCodeReader()
}

async start(currentCamera: number | null, decodeCallback: DecodeContinuouslyCallback) {
// Stop all tracks from media stream before camera changing
if (this.cameraStream) this.stopVideoTracks()

// Request new media stream and attach to video element as source object
const facingMode = currentCamera === 1 ? 'environment' : 'user'
this.cameraStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode },
audio: false
})
this.videoElement.srcObject = this.cameraStream

return this.codeReader.decodeFromVideoElement(this.videoElement, decodeCallback)
}

async getCameras() {
const deviceCameras = (await navigator.mediaDevices.enumerateDevices()).filter(
(device) => device.kind === 'videoinput'
)

// Change only two video devices. First - front camera, second (is available) - back camera
const cameras: MediaDeviceInfo[] = []
if (deviceCameras.length > 0) {
cameras.push(deviceCameras[0])

if (deviceCameras.length > 1) {
let backCamera: MediaDeviceInfo | undefined
if (navigator.userAgent.includes('iPhone')) {
// On iOS devices the device name can be localized
// But the second camera is back
backCamera = deviceCameras[1]
} else {
// Devices with other mobile os may have two front cameras
// So let's take first back camera
backCamera = deviceCameras.slice(1).find((device) => device.label.includes('back'))
}

if (backCamera) cameras.push(backCamera)
}
}

return cameras
}

stop(controls: IScannerControls) {
// Stop all tracks from the requested media stream before controls stopping
this.stopVideoTracks()
controls.stop()
}

private stopVideoTracks() {
this.cameraStream.getVideoTracks().forEach((track) => track.stop())
}
}