2 Commits

Author SHA1 Message Date
712b1238a5 0.6.5
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-02-28 17:34:35 +00:00
3732d365dd refactor: replace vue-qrcode-reader with custom native QR scanner using local BarcodeDetector polyfill 2026-02-28 17:31:04 +00:00
3 changed files with 143 additions and 140 deletions

62
package-lock.json generated
View File

@@ -1,19 +1,18 @@
{
"name": "tools-app",
"version": "0.6.4",
"version": "0.6.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tools-app",
"version": "0.6.4",
"version": "0.6.5",
"hasInstallScript": true,
"dependencies": {
"barcode-detector": "^3.1.0",
"lucide-vue-next": "^0.575.0",
"qrcode": "^1.5.4",
"vue": "^3.5.25",
"vue-qrcode-reader": "^5.7.3",
"vue-router": "^5.0.3"
},
"devDependencies": {
@@ -2518,12 +2517,6 @@
"sourcemap-codec": "^1.4.8"
}
},
"node_modules/@types/dom-webcodecs": {
"version": "0.1.18",
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.18.tgz",
"integrity": "sha512-vAvE8C9DGWR+tkb19xyjk1TSUlJ7RUzzp4a9Anu7mwBT+fpyePWK1UxmH14tMO5zHmrnrRIMg5NutnnDztLxgg==",
"license": "MIT"
},
"node_modules/@types/emscripten": {
"version": "1.41.5",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
@@ -5472,12 +5465,6 @@
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
"license": "MIT"
},
"node_modules/sdp": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz",
"integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==",
"license": "MIT"
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -6381,38 +6368,6 @@
}
}
},
"node_modules/vue-qrcode-reader": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/vue-qrcode-reader/-/vue-qrcode-reader-5.7.3.tgz",
"integrity": "sha512-iSGko42FsEvdHyizBMBs/X+HMO9Z5ONDxjW+mQdoraOR5emRNedmjC5SEJdYzGz8ZP5ME3lwB4iHy3S7MOt5Qw==",
"license": "MIT",
"dependencies": {
"barcode-detector": "2.2.2",
"webrtc-adapter": "8.2.3"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/vue-qrcode-reader/node_modules/barcode-detector": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-2.2.2.tgz",
"integrity": "sha512-JcSekql+EV93evfzF9zBr+Y6aRfkR+QFvgyzbwQ0dbymZXoAI9+WgT7H1E429f+3RKNncHz2CW98VQtaaKpmfQ==",
"license": "MIT",
"dependencies": {
"@types/dom-webcodecs": "^0.1.11",
"zxing-wasm": "1.1.3"
}
},
"node_modules/vue-qrcode-reader/node_modules/zxing-wasm": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-1.1.3.tgz",
"integrity": "sha512-MYm9k/5YVs4ZOTIFwlRjfFKD0crhefgbnt1+6TEpmKUDFp3E2uwqGSKwQOd2hOIsta/7Usq4hnpNRYTLoljnfA==",
"license": "MIT",
"dependencies": {
"@types/emscripten": "^1.39.10"
}
},
"node_modules/vue-router": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.3.tgz",
@@ -6471,19 +6426,6 @@
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"license": "MIT"
},
"node_modules/webrtc-adapter": {
"version": "8.2.3",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-8.2.3.tgz",
"integrity": "sha512-gnmRz++suzmvxtp3ehQts6s2JtAGPuDPjA1F3a9ckNpG1kYdYuHWYpazoAnL9FS5/B21tKlhkorbdCXat0+4xQ==",
"license": "BSD-3-Clause",
"dependencies": {
"sdp": "^3.2.0"
},
"engines": {
"node": ">=6.0.0",
"npm": ">=3.10.0"
}
},
"node_modules/whatwg-url": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "tools-app",
"private": true,
"version": "0.6.4",
"version": "0.6.5",
"type": "module",
"scripts": {
"dev": "vite",
@@ -14,7 +14,6 @@
"lucide-vue-next": "^0.575.0",
"qrcode": "^1.5.4",
"vue": "^3.5.25",
"vue-qrcode-reader": "^5.7.3",
"vue-router": "^5.0.3"
},
"devDependencies": {

View File

@@ -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 {