Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
f3a4c1af05
|
|||
|
616f615d7c
|
|||
|
4d572b55ca
|
|||
|
9822cab93e
|
|||
|
9409bd3e21
|
|||
|
346ded460a
|
|||
|
170539a62f
|
|||
|
cfc1785863
|
|||
|
712b1238a5
|
|||
|
3732d365dd
|
|||
|
34fd8bb2b3
|
|||
|
90dc663393
|
|||
|
1c4bdeff0e
|
|||
|
35c5ff4c51
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,6 +11,7 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
public/wasm
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
93
package-lock.json
generated
93
package-lock.json
generated
@@ -1,17 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "tools-app",
|
"name": "tools-app",
|
||||||
"version": "0.6.2",
|
"version": "0.6.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "tools-app",
|
"name": "tools-app",
|
||||||
"version": "0.6.2",
|
"version": "0.6.9",
|
||||||
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"barcode-detector": "^3.1.0",
|
||||||
"lucide-vue-next": "^0.575.0",
|
"lucide-vue-next": "^0.575.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"vue": "^3.5.25",
|
"vue": "^3.5.25",
|
||||||
"vue-qrcode-reader": "^5.7.3",
|
|
||||||
"vue-router": "^5.0.3"
|
"vue-router": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -2516,12 +2517,6 @@
|
|||||||
"sourcemap-codec": "^1.4.8"
|
"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": {
|
"node_modules/@types/emscripten": {
|
||||||
"version": "1.41.5",
|
"version": "1.41.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
|
||||||
@@ -2946,13 +2941,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/barcode-detector": {
|
"node_modules/barcode-detector": {
|
||||||
"version": "2.2.2",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-2.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.1.0.tgz",
|
||||||
"integrity": "sha512-JcSekql+EV93evfzF9zBr+Y6aRfkR+QFvgyzbwQ0dbymZXoAI9+WgT7H1E429f+3RKNncHz2CW98VQtaaKpmfQ==",
|
"integrity": "sha512-aQjGxrgsb/WTlw6pHZwFRO6NhFMhwHGEkd0pzV25fBn8dnRA1PA1G7bLeAzvSea646S/96nW5W3jD8wezQZ1vQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/dom-webcodecs": "^0.1.11",
|
"zxing-wasm": "3.0.0"
|
||||||
"zxing-wasm": "1.1.3"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
@@ -5471,12 +5465,6 @@
|
|||||||
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
|
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
@@ -5912,6 +5900,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tagged-tag": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/temp-dir": {
|
"node_modules/temp-dir": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
||||||
@@ -6368,19 +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-router": {
|
"node_modules/vue-router": {
|
||||||
"version": "5.0.3",
|
"version": "5.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.3.tgz",
|
||||||
@@ -6439,19 +6426,6 @@
|
|||||||
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/whatwg-url": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
|
||||||
@@ -6982,12 +6956,31 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zxing-wasm": {
|
"node_modules/zxing-wasm": {
|
||||||
"version": "1.1.3",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.0.0.tgz",
|
||||||
"integrity": "sha512-MYm9k/5YVs4ZOTIFwlRjfFKD0crhefgbnt1+6TEpmKUDFp3E2uwqGSKwQOd2hOIsta/7Usq4hnpNRYTLoljnfA==",
|
"integrity": "sha512-s7ASCPKX+QnH7Y83f4Byxmq/vDzYW7B9m6jMP5S30JGfN2A6WAUn6P3vcBmNguDhPLE6ny2fjTooQVyKBXI1qA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/emscripten": "^1.39.10"
|
"@types/emscripten": "^1.41.5",
|
||||||
|
"type-fest": "^5.4.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/emscripten": ">=1.39.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zxing-wasm/node_modules/type-fest": {
|
||||||
|
"version": "5.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz",
|
||||||
|
"integrity": "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==",
|
||||||
|
"license": "(MIT OR CC0-1.0)",
|
||||||
|
"dependencies": {
|
||||||
|
"tagged-tag": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "tools-app",
|
"name": "tools-app",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.6.2",
|
"version": "0.6.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"postinstall": "mkdir -p public/wasm && cp node_modules/zxing-wasm/dist/reader/zxing_reader.wasm public/wasm/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"barcode-detector": "^3.1.0",
|
||||||
"lucide-vue-next": "^0.575.0",
|
"lucide-vue-next": "^0.575.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"vue": "^3.5.25",
|
"vue": "^3.5.25",
|
||||||
"vue-qrcode-reader": "^5.7.3",
|
|
||||||
"vue-router": "^5.0.3"
|
"vue-router": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||||
import { QrcodeStream } from 'vue-qrcode-reader'
|
import { SwitchCamera, Trash2, Copy, Download, X, Maximize2, Minimize2 } from 'lucide-vue-next'
|
||||||
import { SwitchCamera, Trash2, Copy, Download, X } from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const facingMode = ref('environment')
|
const facingMode = ref('environment')
|
||||||
@@ -9,22 +8,117 @@ const scannedCodes = ref([])
|
|||||||
const hasMultipleCameras = ref(false)
|
const hasMultipleCameras = ref(false)
|
||||||
const isFullscreen = ref(false)
|
const isFullscreen = ref(false)
|
||||||
const videoAspect = ref(1)
|
const videoAspect = ref(1)
|
||||||
const isFront = computed(() => facingMode.value === 'user')
|
const isMirrored = ref(false)
|
||||||
const wrapperRef = ref(null)
|
const wrapperRef = ref(null)
|
||||||
let frontMirrorObserver = null
|
|
||||||
const bgCanvas = ref(null)
|
const bgCanvas = ref(null)
|
||||||
let bgRafId = null
|
let bgRafId = null
|
||||||
|
|
||||||
|
// Native scanner state
|
||||||
|
const videoRef = ref(null)
|
||||||
|
let stream = null
|
||||||
|
let scanRafId = null
|
||||||
|
let barcodeDetector = null
|
||||||
|
|
||||||
|
const overlayCanvas = ref(null)
|
||||||
|
|
||||||
|
const paintDetections = (codes) => {
|
||||||
|
const canvas = overlayCanvas.value
|
||||||
|
const video = videoRef.value
|
||||||
|
|
||||||
|
if (!canvas || !video) return
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
const { width, height } = canvas.getBoundingClientRect()
|
||||||
|
|
||||||
|
// Update canvas size if needed (to match CSS size)
|
||||||
|
if (canvas.width !== width || canvas.height !== height) {
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, width, height)
|
||||||
|
|
||||||
|
if (!codes || codes.length === 0) return
|
||||||
|
|
||||||
|
const vw = video.videoWidth
|
||||||
|
const vh = video.videoHeight
|
||||||
|
if (!vw || !vh) return
|
||||||
|
|
||||||
|
// Calculate object-fit: cover scaling
|
||||||
|
const videoRatio = vw / vh
|
||||||
|
const canvasRatio = width / height
|
||||||
|
|
||||||
|
let drawWidth, drawHeight, startX, startY
|
||||||
|
|
||||||
|
if (canvasRatio > videoRatio) {
|
||||||
|
// Canvas is wider than video (video cropped top/bottom)
|
||||||
|
drawWidth = width
|
||||||
|
drawHeight = width / videoRatio
|
||||||
|
startX = 0
|
||||||
|
startY = (height - drawHeight) / 2
|
||||||
|
} else {
|
||||||
|
// Canvas is taller than video (video cropped left/right)
|
||||||
|
drawHeight = height
|
||||||
|
drawWidth = height * videoRatio
|
||||||
|
startY = 0
|
||||||
|
startX = (width - drawWidth) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = drawWidth / vw
|
||||||
|
// Canvas is mirrored via CSS if isMirrored is true, so no manual coordinate mirroring needed
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
const styles = getComputedStyle(document.documentElement)
|
||||||
|
const accent = styles.getPropertyValue('--primary-accent').trim() || '#00f2fe'
|
||||||
|
|
||||||
|
ctx.lineWidth = 4
|
||||||
|
ctx.strokeStyle = accent
|
||||||
|
ctx.fillStyle = accent
|
||||||
|
|
||||||
|
codes.forEach(code => {
|
||||||
|
const points = code.cornerPoints
|
||||||
|
if (!points || points.length < 4) return
|
||||||
|
|
||||||
|
ctx.beginPath()
|
||||||
|
|
||||||
|
const transform = (p) => {
|
||||||
|
let x = p.x * scale + startX
|
||||||
|
let y = p.y * scale + startY
|
||||||
|
return { x, y }
|
||||||
|
}
|
||||||
|
|
||||||
|
const p0 = transform(points[0])
|
||||||
|
ctx.moveTo(p0.x, p0.y)
|
||||||
|
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
const p = transform(points[i])
|
||||||
|
ctx.lineTo(p.x, p.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
// Draw corners
|
||||||
|
points.forEach(p => {
|
||||||
|
const tp = transform(p)
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(tp.x, tp.y, 4, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const updateVideoAspect = () => {
|
const updateVideoAspect = () => {
|
||||||
const videoEl = document.querySelector('.camera-wrapper video')
|
if (videoRef.value && videoRef.value.videoWidth && videoRef.value.videoHeight) {
|
||||||
if (videoEl && videoEl.videoWidth && videoEl.videoHeight) {
|
videoAspect.value = videoRef.value.videoWidth / videoRef.value.videoHeight
|
||||||
videoAspect.value = videoEl.videoWidth / videoEl.videoHeight
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const startBackgroundLoop = () => {
|
const startBackgroundLoop = () => {
|
||||||
const draw = () => {
|
const draw = () => {
|
||||||
const videoEl = document.querySelector('.camera-wrapper video')
|
const videoEl = videoRef.value
|
||||||
const canvas = bgCanvas.value
|
const canvas = bgCanvas.value
|
||||||
if (!videoEl || !canvas) {
|
if (!videoEl || !canvas || videoEl.paused || videoEl.ended) {
|
||||||
bgRafId = requestAnimationFrame(draw)
|
bgRafId = requestAnimationFrame(draw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -56,13 +150,114 @@ const startBackgroundLoop = () => {
|
|||||||
bgRafId = requestAnimationFrame(draw)
|
bgRafId = requestAnimationFrame(draw)
|
||||||
}
|
}
|
||||||
|
|
||||||
// front mirror canvas removed to restore stable behavior
|
|
||||||
const stopBackgroundLoop = () => {
|
const stopBackgroundLoop = () => {
|
||||||
if (bgRafId) {
|
if (bgRafId) {
|
||||||
cancelAnimationFrame(bgRafId)
|
cancelAnimationFrame(bgRafId)
|
||||||
bgRafId = null
|
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)
|
||||||
|
|
||||||
|
// Detect actual facing mode to mirror front camera correctly
|
||||||
|
const videoTrack = stream.getVideoTracks()[0]
|
||||||
|
if (videoTrack) {
|
||||||
|
const settings = videoTrack.getSettings()
|
||||||
|
if (settings.facingMode) {
|
||||||
|
isMirrored.value = settings.facingMode === 'user'
|
||||||
|
} else {
|
||||||
|
// Fallback: check label for desktop cameras or assume requested mode
|
||||||
|
const label = videoTrack.label ? videoTrack.label.toLowerCase() : ''
|
||||||
|
if (label.includes('front') || label.includes('facetime') || label.includes('macbook')) {
|
||||||
|
isMirrored.value = true
|
||||||
|
} else {
|
||||||
|
isMirrored.value = facingMode.value === 'user'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
paintDetections(codes)
|
||||||
|
if (codes.length > 0) {
|
||||||
|
onDetect(codes)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// console.error('Detection error', e)
|
||||||
|
}
|
||||||
|
scanRafId = requestAnimationFrame(detectLoop)
|
||||||
|
}
|
||||||
|
|
||||||
const desktopFullscreenStyle = computed(() => {
|
const desktopFullscreenStyle = computed(() => {
|
||||||
if (!isFullscreen.value) return {}
|
if (!isFullscreen.value) return {}
|
||||||
const isDesktop = window.innerWidth >= 768
|
const isDesktop = window.innerWidth >= 768
|
||||||
@@ -140,61 +335,10 @@ const onDetect = (detectedCodes) => {
|
|||||||
processCodes(validCodes)
|
processCodes(validCodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onCameraOn = async (capabilities) => {
|
watch(facingMode, () => {
|
||||||
// Camera is ready
|
startScan()
|
||||||
setTimeout(updateVideoAspect, 100)
|
|
||||||
setTimeout(startBackgroundLoop, 150)
|
|
||||||
// Flip is handled via global CSS; no JS flips needed
|
|
||||||
}
|
|
||||||
|
|
||||||
const ensureFrontMirror = () => {
|
|
||||||
// No-op: mirror is applied via CSS selectors
|
|
||||||
}
|
|
||||||
|
|
||||||
const startFrontMirrorObserver = () => {
|
|
||||||
// No-op: mirror is applied via CSS selectors
|
|
||||||
}
|
|
||||||
|
|
||||||
const stopFrontMirrorObserver = () => {
|
|
||||||
// No-op
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(isFront, () => {
|
|
||||||
// CSS-based; nothing to do
|
|
||||||
})
|
})
|
||||||
|
|
||||||
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 onError = (err) => {
|
const onError = (err) => {
|
||||||
if (err.name === 'NotAllowedError') {
|
if (err.name === 'NotAllowedError') {
|
||||||
error.value = 'Camera permission denied'
|
error.value = 'Camera permission denied'
|
||||||
@@ -245,12 +389,14 @@ onMounted(() => {
|
|||||||
stopBackgroundLoop()
|
stopBackgroundLoop()
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
|
startScan()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('resize', updateVideoAspect)
|
window.removeEventListener('resize', updateVideoAspect)
|
||||||
window.removeEventListener('resize', startBackgroundLoop)
|
window.removeEventListener('resize', startBackgroundLoop)
|
||||||
stopBackgroundLoop()
|
stopScan()
|
||||||
})
|
})
|
||||||
|
|
||||||
const switchCamera = (event) => {
|
const switchCamera = (event) => {
|
||||||
@@ -331,31 +477,35 @@ const isUrl = (string) => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class="camera-wrapper"
|
class="camera-wrapper"
|
||||||
:class="{ 'clickable': !isFullscreen, 'is-front': isFront }"
|
:class="{ 'clickable': !isFullscreen, 'is-mirrored': isMirrored }"
|
||||||
:style="desktopFullscreenStyle"
|
:style="desktopFullscreenStyle"
|
||||||
ref="wrapperRef"
|
ref="wrapperRef"
|
||||||
@click="!isFullscreen && toggleFullscreen()"
|
@click="!isFullscreen && toggleFullscreen()"
|
||||||
>
|
>
|
||||||
<QrcodeStream
|
<video
|
||||||
:constraints="{ facingMode }"
|
ref="videoRef"
|
||||||
@detect="onDetect"
|
class="camera-feed"
|
||||||
@error="onError"
|
:class="{ 'is-mirrored': isMirrored }"
|
||||||
@camera-on="onCameraOn"
|
autoplay
|
||||||
:track="paintDetections"
|
playsinline
|
||||||
>
|
muted
|
||||||
<div v-if="error" class="error-overlay">
|
></video>
|
||||||
<p>{{ error }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<canvas ref="overlayCanvas" class="scan-overlay-canvas" :class="{ 'is-mirrored': isMirrored }"></canvas>
|
||||||
v-if="hasMultipleCameras"
|
|
||||||
class="switch-camera-btn"
|
<div v-if="error" class="error-overlay">
|
||||||
@click.stop="switchCamera"
|
<p>{{ error }}</p>
|
||||||
title="Switch Camera"
|
<button @click="startScan" class="retry-btn">Retry</button>
|
||||||
>
|
</div>
|
||||||
<SwitchCamera size="24" />
|
|
||||||
</button>
|
<button
|
||||||
</QrcodeStream>
|
v-if="hasMultipleCameras"
|
||||||
|
class="switch-camera-btn"
|
||||||
|
@click.stop="switchCamera"
|
||||||
|
title="Switch Camera"
|
||||||
|
>
|
||||||
|
<SwitchCamera size="24" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="results-section">
|
<div class="results-section">
|
||||||
@@ -488,6 +638,31 @@ const isUrl = (string) => {
|
|||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.camera-feed {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-feed.is-mirrored {
|
||||||
|
transform: scaleX(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-overlay-canvas {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-overlay-canvas.is-mirrored {
|
||||||
|
transform: scaleX(-1);
|
||||||
|
}
|
||||||
|
|
||||||
/* front mirror canvas removed */
|
/* front mirror canvas removed */
|
||||||
|
|
||||||
.error-overlay {
|
.error-overlay {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { onMounted, onUnmounted, ref, nextTick } from 'vue'
|
import { onMounted, onUnmounted, ref, nextTick, watch } from 'vue'
|
||||||
import { UI_CONFIG } from '../config/ui'
|
import { UI_CONFIG } from '../config/ui'
|
||||||
|
|
||||||
export function useFillHeight(elementRef, extraMargin = 0) {
|
export function useFillHeight(elementRef, extraMargin = 0) {
|
||||||
@@ -29,6 +29,13 @@ export function useFillHeight(elementRef, extraMargin = 0) {
|
|||||||
|
|
||||||
// Also update after a short delay to ensure layout is settled (e.g. sidebar transitions)
|
// Also update after a short delay to ensure layout is settled (e.g. sidebar transitions)
|
||||||
setTimeout(updateHeight, 300)
|
setTimeout(updateHeight, 300)
|
||||||
|
|
||||||
|
// Watch for element appearing (v-if) or changing
|
||||||
|
watch(elementRef, () => {
|
||||||
|
nextTick(updateHeight)
|
||||||
|
// Additional update for layout stability
|
||||||
|
setTimeout(updateHeight, 100)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|||||||
21
src/main.js
21
src/main.js
@@ -3,6 +3,27 @@ import './style.css'
|
|||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import Ripple from './directives/ripple'
|
import Ripple from './directives/ripple'
|
||||||
|
import { BarcodeDetector, prepareZXingModule } from 'barcode-detector/ponyfill'
|
||||||
|
|
||||||
|
// Configure BarcodeDetector polyfill to use local WASM file
|
||||||
|
try {
|
||||||
|
prepareZXingModule({
|
||||||
|
overrides: {
|
||||||
|
locateFile: (path, prefix) => {
|
||||||
|
if (path.endsWith('.wasm')) {
|
||||||
|
return '/wasm/zxing_reader.wasm'
|
||||||
|
}
|
||||||
|
return prefix + path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Force usage of polyfill to avoid CSP issues and ensure consistent behavior
|
||||||
|
// Native implementation might fail or be missing in some browsers
|
||||||
|
window.BarcodeDetector = BarcodeDetector
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to initialize BarcodeDetector polyfill', e)
|
||||||
|
}
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user