refactor: replace vue-qrcode-reader with custom native QR scanner using local BarcodeDetector polyfill
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||
import { QrcodeStream } from 'vue-qrcode-reader'
|
||||
import { SwitchCamera, Trash2, Copy, Download, X } from 'lucide-vue-next'
|
||||
import { SwitchCamera, Trash2, Copy, Download, X, Maximize2, Minimize2 } from 'lucide-vue-next'
|
||||
|
||||
const error = ref('')
|
||||
const facingMode = ref('environment')
|
||||
@@ -11,20 +10,26 @@ const isFullscreen = ref(false)
|
||||
const videoAspect = ref(1)
|
||||
const isFront = computed(() => facingMode.value === 'user')
|
||||
const wrapperRef = ref(null)
|
||||
let frontMirrorObserver = null
|
||||
const bgCanvas = ref(null)
|
||||
let bgRafId = null
|
||||
|
||||
// Native scanner state
|
||||
const videoRef = ref(null)
|
||||
let stream = null
|
||||
let scanRafId = null
|
||||
let barcodeDetector = null
|
||||
|
||||
const updateVideoAspect = () => {
|
||||
const videoEl = document.querySelector('.camera-wrapper video')
|
||||
if (videoEl && videoEl.videoWidth && videoEl.videoHeight) {
|
||||
videoAspect.value = videoEl.videoWidth / videoEl.videoHeight
|
||||
if (videoRef.value && videoRef.value.videoWidth && videoRef.value.videoHeight) {
|
||||
videoAspect.value = videoRef.value.videoWidth / videoRef.value.videoHeight
|
||||
}
|
||||
}
|
||||
|
||||
const startBackgroundLoop = () => {
|
||||
const draw = () => {
|
||||
const videoEl = document.querySelector('.camera-wrapper video')
|
||||
const videoEl = videoRef.value
|
||||
const canvas = bgCanvas.value
|
||||
if (!videoEl || !canvas) {
|
||||
if (!videoEl || !canvas || videoEl.paused || videoEl.ended) {
|
||||
bgRafId = requestAnimationFrame(draw)
|
||||
return
|
||||
}
|
||||
@@ -56,13 +61,96 @@ const startBackgroundLoop = () => {
|
||||
bgRafId = requestAnimationFrame(draw)
|
||||
}
|
||||
|
||||
// front mirror canvas removed to restore stable behavior
|
||||
const stopBackgroundLoop = () => {
|
||||
if (bgRafId) {
|
||||
cancelAnimationFrame(bgRafId)
|
||||
bgRafId = null
|
||||
}
|
||||
}
|
||||
|
||||
const initDetector = async () => {
|
||||
if (!barcodeDetector) {
|
||||
if ('BarcodeDetector' in window) {
|
||||
try {
|
||||
// Formats are optional, but specifying qr_code might be faster
|
||||
const formats = await window.BarcodeDetector.getSupportedFormats()
|
||||
if (formats.includes('qr_code')) {
|
||||
barcodeDetector = new window.BarcodeDetector({ formats: ['qr_code'] })
|
||||
} else {
|
||||
barcodeDetector = new window.BarcodeDetector()
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback
|
||||
barcodeDetector = new window.BarcodeDetector()
|
||||
}
|
||||
} else {
|
||||
error.value = 'Barcode Detection API not supported'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const startScan = async () => {
|
||||
stopScan()
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
await initDetector()
|
||||
if (!barcodeDetector) {
|
||||
error.value = 'Barcode Detector failed to initialize'
|
||||
return
|
||||
}
|
||||
|
||||
const constraints = {
|
||||
video: {
|
||||
facingMode: facingMode.value,
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 }
|
||||
}
|
||||
}
|
||||
|
||||
stream = await navigator.mediaDevices.getUserMedia(constraints)
|
||||
|
||||
if (videoRef.value) {
|
||||
videoRef.value.srcObject = stream
|
||||
// Wait for metadata to play
|
||||
videoRef.value.onloadedmetadata = () => {
|
||||
videoRef.value.play().catch(e => console.error('Play error', e))
|
||||
updateVideoAspect()
|
||||
detectLoop()
|
||||
startBackgroundLoop()
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
onError(err)
|
||||
}
|
||||
}
|
||||
|
||||
const stopScan = () => {
|
||||
if (scanRafId) cancelAnimationFrame(scanRafId)
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(t => t.stop())
|
||||
stream = null
|
||||
}
|
||||
stopBackgroundLoop()
|
||||
}
|
||||
|
||||
const detectLoop = async () => {
|
||||
if (!videoRef.value || videoRef.value.paused || videoRef.value.ended) {
|
||||
scanRafId = requestAnimationFrame(detectLoop)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const codes = await barcodeDetector.detect(videoRef.value)
|
||||
if (codes.length > 0) {
|
||||
onDetect(codes)
|
||||
}
|
||||
} catch (e) {
|
||||
// console.error('Detection error', e)
|
||||
}
|
||||
scanRafId = requestAnimationFrame(detectLoop)
|
||||
}
|
||||
|
||||
const desktopFullscreenStyle = computed(() => {
|
||||
if (!isFullscreen.value) return {}
|
||||
const isDesktop = window.innerWidth >= 768
|
||||
@@ -140,61 +228,20 @@ const onDetect = (detectedCodes) => {
|
||||
processCodes(validCodes)
|
||||
}
|
||||
|
||||
const onCameraOn = async (capabilities) => {
|
||||
// Camera is ready
|
||||
setTimeout(updateVideoAspect, 100)
|
||||
setTimeout(startBackgroundLoop, 150)
|
||||
// Flip is handled via global CSS; no JS flips needed
|
||||
}
|
||||
const onCameraOn = async () => {} // Removed
|
||||
|
||||
const ensureFrontMirror = () => {
|
||||
// No-op: mirror is applied via CSS selectors
|
||||
}
|
||||
const ensureFrontMirror = () => {} // Removed
|
||||
|
||||
const startFrontMirrorObserver = () => {
|
||||
// No-op: mirror is applied via CSS selectors
|
||||
}
|
||||
const startFrontMirrorObserver = () => {} // Removed
|
||||
|
||||
const stopFrontMirrorObserver = () => {
|
||||
// No-op
|
||||
}
|
||||
const stopFrontMirrorObserver = () => {} // Removed
|
||||
|
||||
watch(isFront, () => {
|
||||
// CSS-based; nothing to do
|
||||
watch(facingMode, () => {
|
||||
startScan()
|
||||
})
|
||||
|
||||
const paintDetections = (detectedCodes, ctx) => {
|
||||
try {
|
||||
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
|
||||
const styles = getComputedStyle(document.documentElement)
|
||||
const accent = styles.getPropertyValue('--primary-accent').trim() || '#00f2fe'
|
||||
detectedCodes.forEach(code => {
|
||||
if (code.format && code.format !== 'qr_code') return
|
||||
const points = code.cornerPoints || []
|
||||
if (points.length < 4) return
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(points[0].x, points[0].y)
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
ctx.lineTo(points[i].x, points[i].y)
|
||||
}
|
||||
ctx.closePath()
|
||||
ctx.lineWidth = 3
|
||||
ctx.strokeStyle = accent
|
||||
ctx.shadowColor = accent
|
||||
ctx.shadowBlur = 8
|
||||
ctx.stroke()
|
||||
ctx.shadowBlur = 0
|
||||
ctx.fillStyle = accent
|
||||
points.forEach(p => {
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, 2.5, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
})
|
||||
})
|
||||
} catch (e) {
|
||||
// ignore drawing errors
|
||||
}
|
||||
}
|
||||
const paintDetections = () => {} // Removed, we don't paint detections on video anymore (too complex without overlay canvas)
|
||||
|
||||
const onError = (err) => {
|
||||
if (err.name === 'NotAllowedError') {
|
||||
error.value = 'Camera permission denied'
|
||||
@@ -245,12 +292,14 @@ onMounted(() => {
|
||||
stopBackgroundLoop()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
startScan()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updateVideoAspect)
|
||||
window.removeEventListener('resize', startBackgroundLoop)
|
||||
stopBackgroundLoop()
|
||||
stopScan()
|
||||
})
|
||||
|
||||
const switchCamera = (event) => {
|
||||
@@ -336,26 +385,28 @@ const isUrl = (string) => {
|
||||
ref="wrapperRef"
|
||||
@click="!isFullscreen && toggleFullscreen()"
|
||||
>
|
||||
<QrcodeStream
|
||||
:constraints="{ facingMode }"
|
||||
@detect="onDetect"
|
||||
@error="onError"
|
||||
@camera-on="onCameraOn"
|
||||
:track="paintDetections"
|
||||
>
|
||||
<div v-if="error" class="error-overlay">
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
<video
|
||||
ref="videoRef"
|
||||
class="camera-feed"
|
||||
:class="{ 'is-front': isFront }"
|
||||
autoplay
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
|
||||
<button
|
||||
v-if="hasMultipleCameras"
|
||||
class="switch-camera-btn"
|
||||
@click.stop="switchCamera"
|
||||
title="Switch Camera"
|
||||
>
|
||||
<SwitchCamera size="24" />
|
||||
</button>
|
||||
</QrcodeStream>
|
||||
<div v-if="error" class="error-overlay">
|
||||
<p>{{ error }}</p>
|
||||
<button @click="startScan" class="retry-btn">Retry</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="hasMultipleCameras"
|
||||
class="switch-camera-btn"
|
||||
@click.stop="switchCamera"
|
||||
title="Switch Camera"
|
||||
>
|
||||
<SwitchCamera size="24" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="results-section">
|
||||
@@ -488,6 +539,17 @@ const isUrl = (string) => {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.camera-feed {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.camera-feed.is-front {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
/* front mirror canvas removed */
|
||||
|
||||
.error-overlay {
|
||||
|
||||
Reference in New Issue
Block a user