799 lines
19 KiB
Vue
799 lines
19 KiB
Vue
<script setup>
|
|
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
|
import { SwitchCamera, Trash2, Copy, Download, X, Maximize2, Minimize2 } from 'lucide-vue-next'
|
|
|
|
const error = ref('')
|
|
const facingMode = ref('environment')
|
|
const scannedCodes = ref([])
|
|
const hasMultipleCameras = ref(false)
|
|
const isFullscreen = ref(false)
|
|
const videoAspect = ref(1)
|
|
const isFront = computed(() => facingMode.value === 'user')
|
|
const wrapperRef = ref(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 = () => {
|
|
if (videoRef.value && videoRef.value.videoWidth && videoRef.value.videoHeight) {
|
|
videoAspect.value = videoRef.value.videoWidth / videoRef.value.videoHeight
|
|
}
|
|
}
|
|
|
|
const startBackgroundLoop = () => {
|
|
const draw = () => {
|
|
const videoEl = videoRef.value
|
|
const canvas = bgCanvas.value
|
|
if (!videoEl || !canvas || videoEl.paused || videoEl.ended) {
|
|
bgRafId = requestAnimationFrame(draw)
|
|
return
|
|
}
|
|
const vw = videoEl.videoWidth || 0
|
|
const vh = videoEl.videoHeight || 0
|
|
if (!vw || !vh) {
|
|
bgRafId = requestAnimationFrame(draw)
|
|
return
|
|
}
|
|
const cw = Math.floor(window.innerWidth)
|
|
const ch = Math.floor(window.innerHeight * 0.5)
|
|
if (canvas.width !== cw || canvas.height !== ch) {
|
|
canvas.width = cw
|
|
canvas.height = ch
|
|
}
|
|
const ctx = canvas.getContext('2d')
|
|
if (ctx) {
|
|
// cover horizontally: scale by width, crop top/bottom
|
|
const scale = cw / vw
|
|
const srcH = ch / scale
|
|
const sx = 0
|
|
const sy = Math.max(0, (vh - srcH) / 2)
|
|
ctx.clearRect(0, 0, cw, ch)
|
|
ctx.drawImage(videoEl, sx, sy, vw, srcH, 0, 0, cw, ch)
|
|
}
|
|
bgRafId = requestAnimationFrame(draw)
|
|
}
|
|
if (bgRafId) cancelAnimationFrame(bgRafId)
|
|
bgRafId = requestAnimationFrame(draw)
|
|
}
|
|
|
|
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
|
|
if (!isDesktop) return {}
|
|
const halfHeight = Math.floor(window.innerHeight * 0.5)
|
|
const widthPx = Math.min(window.innerWidth, Math.floor(halfHeight * videoAspect.value))
|
|
return { height: `${halfHeight}px`, width: `${widthPx}px`, margin: '0 auto' }
|
|
})
|
|
|
|
const processCodes = (codes) => {
|
|
codes.forEach(code => {
|
|
const value = code.rawValue
|
|
if (value && !scannedCodes.value.some(c => c.value === value)) {
|
|
scannedCodes.value.unshift({
|
|
id: Date.now() + Math.random(),
|
|
value,
|
|
format: code.format,
|
|
timestamp: new Date().toLocaleTimeString()
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
const onDetect = (detectedCodes) => {
|
|
// If fullscreen, accept all detected codes (as the user sees the full camera view mostly)
|
|
if (isFullscreen.value) {
|
|
processCodes(detectedCodes)
|
|
return
|
|
}
|
|
|
|
// Try to find video element to calculate visible area
|
|
const videoEl = document.querySelector('.camera-wrapper video')
|
|
|
|
if (!videoEl || !videoEl.videoWidth || !videoEl.videoHeight) {
|
|
processCodes(detectedCodes)
|
|
return
|
|
}
|
|
|
|
const { videoWidth, videoHeight } = videoEl
|
|
|
|
// Calculate visible square area (assuming object-fit: cover and 1:1 container)
|
|
const isLandscape = videoWidth > videoHeight
|
|
let visibleX, visibleY, visibleW, visibleH
|
|
|
|
if (isLandscape) {
|
|
// Landscape: sides are cropped, height is fully visible
|
|
visibleH = videoHeight
|
|
visibleW = videoHeight // Square
|
|
visibleX = (videoWidth - videoHeight) / 2
|
|
visibleY = 0
|
|
} else {
|
|
// Portrait: top/bottom are cropped, width is fully visible
|
|
visibleW = videoWidth
|
|
visibleH = videoWidth // Square
|
|
visibleX = 0
|
|
visibleY = (videoHeight - videoWidth) / 2
|
|
}
|
|
|
|
// Add margin to be safe (code center must be within visible area)
|
|
// We allow codes slightly outside if their center is inside
|
|
const validCodes = detectedCodes.filter(code => {
|
|
if (!code.boundingBox) return true
|
|
const { x, y, width, height } = code.boundingBox
|
|
const centerX = x + width / 2
|
|
const centerY = y + height / 2
|
|
|
|
return (
|
|
centerX >= visibleX &&
|
|
centerX <= visibleX + visibleW &&
|
|
centerY >= visibleY &&
|
|
centerY <= visibleY + visibleH
|
|
)
|
|
})
|
|
|
|
processCodes(validCodes)
|
|
}
|
|
|
|
const onCameraOn = async () => {} // Removed
|
|
|
|
const ensureFrontMirror = () => {} // Removed
|
|
|
|
const startFrontMirrorObserver = () => {} // Removed
|
|
|
|
const stopFrontMirrorObserver = () => {} // Removed
|
|
|
|
watch(facingMode, () => {
|
|
startScan()
|
|
})
|
|
|
|
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'
|
|
} else if (err.name === 'NotFoundError') {
|
|
error.value = 'No camera found'
|
|
} else {
|
|
error.value = `Camera error: ${err.name}`
|
|
}
|
|
}
|
|
|
|
const checkCameras = async () => {
|
|
try {
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
|
|
return
|
|
}
|
|
const devices = await navigator.mediaDevices.enumerateDevices()
|
|
const cameras = devices.filter(d => d.kind === 'videoinput')
|
|
hasMultipleCameras.value = cameras.length > 1
|
|
} catch (e) {
|
|
console.error('Error checking cameras:', e)
|
|
}
|
|
}
|
|
|
|
const loadHistory = () => {
|
|
try {
|
|
const saved = localStorage.getItem('qr-history')
|
|
if (saved) {
|
|
scannedCodes.value = JSON.parse(saved)
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load QR history', e)
|
|
}
|
|
}
|
|
|
|
watch(scannedCodes, (newVal) => {
|
|
localStorage.setItem('qr-history', JSON.stringify(newVal))
|
|
}, { deep: true })
|
|
|
|
onMounted(() => {
|
|
checkCameras()
|
|
loadHistory()
|
|
window.addEventListener('resize', updateVideoAspect)
|
|
window.addEventListener('resize', startBackgroundLoop)
|
|
watch(isFullscreen, (fs) => {
|
|
if (fs) {
|
|
startBackgroundLoop()
|
|
} else {
|
|
stopBackgroundLoop()
|
|
}
|
|
}, { immediate: true })
|
|
|
|
startScan()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('resize', updateVideoAspect)
|
|
window.removeEventListener('resize', startBackgroundLoop)
|
|
stopScan()
|
|
})
|
|
|
|
const switchCamera = (event) => {
|
|
event.stopPropagation()
|
|
facingMode.value = facingMode.value === 'environment' ? 'user' : 'environment'
|
|
}
|
|
|
|
const toggleFullscreen = () => {
|
|
isFullscreen.value = !isFullscreen.value
|
|
}
|
|
|
|
const clearHistory = () => {
|
|
scannedCodes.value = []
|
|
}
|
|
|
|
const removeCode = (id) => {
|
|
scannedCodes.value = scannedCodes.value.filter(c => c.id !== id)
|
|
}
|
|
|
|
const copyAll = async () => {
|
|
if (scannedCodes.value.length === 0) return
|
|
const text = scannedCodes.value.map(c => c.value).join('\n')
|
|
try {
|
|
await navigator.clipboard.writeText(text)
|
|
} catch (err) {
|
|
console.error('Failed to copy', err)
|
|
}
|
|
}
|
|
|
|
const downloadJson = () => {
|
|
if (scannedCodes.value.length === 0) return
|
|
const data = JSON.stringify(scannedCodes.value, null, 2)
|
|
const blob = new Blob([data], { type: 'application/json' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `qr-scan-history-${new Date().toISOString().slice(0, 10)}.json`
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
const copyToClipboard = async (text) => {
|
|
try {
|
|
await navigator.clipboard.writeText(text)
|
|
} catch (err) {
|
|
console.error('Failed to copy', err)
|
|
}
|
|
}
|
|
|
|
const isUrl = (string) => {
|
|
try {
|
|
return Boolean(new URL(string))
|
|
} catch (_) {
|
|
return false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="tool-container full-width">
|
|
<div class="tool-panel">
|
|
<div class="panel-header" v-if="!isFullscreen">
|
|
<h2 class="tool-title">QR Scanner</h2>
|
|
</div>
|
|
|
|
<Teleport to="body" :disabled="!isFullscreen">
|
|
<div class="scanner-content" :class="{ 'is-fullscreen': isFullscreen }">
|
|
<canvas
|
|
v-if="isFullscreen"
|
|
ref="bgCanvas"
|
|
class="camera-bg"
|
|
></canvas>
|
|
<button v-if="isFullscreen" class="close-fullscreen-btn" @click="toggleFullscreen">
|
|
<X size="24" />
|
|
</button>
|
|
|
|
<div
|
|
class="camera-wrapper"
|
|
:class="{ 'clickable': !isFullscreen, 'is-front': isFront }"
|
|
:style="desktopFullscreenStyle"
|
|
ref="wrapperRef"
|
|
@click="!isFullscreen && toggleFullscreen()"
|
|
>
|
|
<video
|
|
ref="videoRef"
|
|
class="camera-feed"
|
|
:class="{ 'is-front': isFront }"
|
|
autoplay
|
|
playsinline
|
|
muted
|
|
></video>
|
|
|
|
<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">
|
|
<div class="results-header">
|
|
<h3>Scanned Codes ({{ scannedCodes.length }})</h3>
|
|
<div v-if="scannedCodes.length > 0" class="header-actions">
|
|
<button class="icon-btn" @click="copyAll" title="Copy All">
|
|
<Copy size="18" />
|
|
</button>
|
|
<button class="icon-btn" @click="downloadJson" title="Download JSON">
|
|
<Download size="18" />
|
|
</button>
|
|
<button class="icon-btn delete-btn" @click="clearHistory" title="Clear All">
|
|
<Trash2 size="18" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="scannedCodes.length > 0" class="codes-list">
|
|
<div v-for="code in scannedCodes" :key="code.id" class="code-item">
|
|
<div class="code-content">
|
|
<div class="code-value">
|
|
<a v-if="isUrl(code.value)" :href="code.value" target="_blank" rel="noopener noreferrer">{{ code.value }}</a>
|
|
<span v-else>{{ code.value }}</span>
|
|
</div>
|
|
<div class="code-meta">
|
|
<span class="timestamp">{{ code.timestamp }}</span>
|
|
<span class="format-badge">{{ code.format }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="item-actions">
|
|
<button class="icon-btn" @click="copyToClipboard(code.value)" title="Copy">
|
|
<Copy size="18" />
|
|
</button>
|
|
<button class="icon-btn delete-btn" @click="removeCode(code.id)" title="Remove">
|
|
<Trash2 size="18" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="empty-state">
|
|
Point camera at a QR code to scan
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.tool-container.full-width {
|
|
max-width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.tool-panel {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
gap: 1.5rem;
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.panel-header {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
margin-bottom: 0.5rem;
|
|
margin-top: 0;
|
|
position: relative;
|
|
width: 100%;
|
|
}
|
|
|
|
.scanner-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
gap: 1.5rem;
|
|
}
|
|
|
|
.scanner-content.is-fullscreen {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 9999;
|
|
background: #000;
|
|
gap: 0;
|
|
}
|
|
|
|
.camera-wrapper {
|
|
width: 100%;
|
|
max-width: 500px;
|
|
aspect-ratio: 1;
|
|
margin: 0 auto;
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
position: relative;
|
|
background: #000;
|
|
border: 1px solid var(--glass-border);
|
|
box-shadow: var(--glass-shadow);
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.camera-wrapper.clickable {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.scanner-content.is-fullscreen .camera-wrapper {
|
|
position: relative;
|
|
width: 100%;
|
|
aspect-ratio: 1;
|
|
max-width: none;
|
|
height: auto;
|
|
border-radius: 0;
|
|
border: none;
|
|
margin: 0;
|
|
z-index: 1;
|
|
}
|
|
|
|
.camera-bg {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
width: 100vw;
|
|
height: 50vh;
|
|
filter: blur(16px) saturate(110%);
|
|
opacity: 0.9;
|
|
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 {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: rgba(0, 0, 0, 0.8);
|
|
color: #ff4444;
|
|
padding: 1rem;
|
|
text-align: center;
|
|
z-index: 10;
|
|
}
|
|
|
|
.switch-camera-btn {
|
|
position: absolute;
|
|
top: 1rem;
|
|
right: 1rem;
|
|
background: rgba(0, 0, 0, 0.4);
|
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
color: #fff;
|
|
border-radius: 50%;
|
|
width: 44px;
|
|
height: 44px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
z-index: 20;
|
|
backdrop-filter: blur(4px);
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.switch-camera-btn:hover {
|
|
background: rgba(0, 0, 0, 0.6);
|
|
transform: scale(1.05);
|
|
}
|
|
|
|
.close-fullscreen-btn {
|
|
position: absolute;
|
|
top: 1rem;
|
|
left: 1rem;
|
|
z-index: 20;
|
|
background: rgba(0, 0, 0, 0.4);
|
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
color: #fff;
|
|
border-radius: 50%;
|
|
width: 44px;
|
|
height: 44px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
backdrop-filter: blur(4px);
|
|
}
|
|
|
|
/* Removed legacy scan frame overlay - using shape detection rendering via track instead */
|
|
|
|
.results-section {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
background: var(--glass-bg);
|
|
border: 1px solid var(--glass-border);
|
|
border-radius: 12px;
|
|
}
|
|
|
|
.scanner-content.is-fullscreen .results-section {
|
|
position: relative;
|
|
flex: 1;
|
|
width: 100%;
|
|
height: auto;
|
|
z-index: 2;
|
|
border-radius: 0;
|
|
background: var(--glass-bg);
|
|
backdrop-filter: blur(10px);
|
|
border: none;
|
|
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.results-header {
|
|
padding: 1rem;
|
|
border-bottom: 1px solid var(--glass-border);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.results-header h3 {
|
|
margin: 0;
|
|
font-size: 1.1rem;
|
|
color: var(--text-strong);
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.codes-list {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 0;
|
|
}
|
|
|
|
.code-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 1rem;
|
|
border-bottom: 1px solid var(--glass-border);
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.code-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.code-item:hover {
|
|
background: var(--list-hover-bg);
|
|
}
|
|
|
|
.code-content {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
padding-right: 1rem;
|
|
}
|
|
|
|
.code-value {
|
|
color: var(--primary-accent);
|
|
font-family: monospace;
|
|
word-break: break-all;
|
|
margin-bottom: 0.4rem;
|
|
}
|
|
|
|
.code-value a {
|
|
color: var(--primary-accent);
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.code-value a:hover {
|
|
opacity: 0.8;
|
|
}
|
|
|
|
.code-meta {
|
|
display: flex;
|
|
gap: 1rem;
|
|
font-size: 0.85rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.item-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
}
|
|
|
|
.format-badge {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
padding: 0 0.4rem;
|
|
border-radius: 4px;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
:global(:root[data-theme="light"]) .format-badge {
|
|
background: rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.icon-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
padding: 0.4rem;
|
|
border-radius: 4px;
|
|
transition: all 0.2s;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.icon-btn:hover {
|
|
color: var(--text-color);
|
|
background: rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
:global(:root[data-theme="light"]) .icon-btn:hover {
|
|
background: rgba(0, 0, 0, 0.05);
|
|
}
|
|
|
|
.delete-btn:hover {
|
|
color: #ef4444;
|
|
}
|
|
|
|
.empty-state {
|
|
flex: 1;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
color: var(--text-secondary);
|
|
padding: 2rem;
|
|
text-align: center;
|
|
}
|
|
|
|
:deep(video) {
|
|
object-fit: cover !important;
|
|
width: 100% !important;
|
|
height: 100% !important;
|
|
position: absolute !important;
|
|
left: 0 !important;
|
|
top: 0 !important;
|
|
}
|
|
|
|
:deep(.qrcode-stream-wrapper),
|
|
:deep(.qrcode-stream-overlay) {
|
|
width: 100% !important;
|
|
height: 100% !important;
|
|
position: absolute !important;
|
|
inset: 0 !important;
|
|
}
|
|
|
|
/* Front camera mirror (CSS-only) */
|
|
.camera-wrapper.is-front :deep(video) {
|
|
transform: scaleX(-1);
|
|
transform-origin: center;
|
|
}
|
|
.camera-wrapper.is-front :deep(#qrcode-stream-pause-frame),
|
|
.camera-wrapper.is-front :deep(#qrcode-stream-overlay) {
|
|
transform: scaleX(-1);
|
|
transform-origin: center;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
:deep(.scanner-content.is-fullscreen .camera-wrapper video) {
|
|
object-fit: contain !important;
|
|
}
|
|
}
|
|
</style>
|