Fix barcode scanner on iOS Safari
iOS WebKit does not provide a reliable native Barcode Detection API, and ZXing often failed due to strict camera constraints and video startup timing. - Install @undecaf/barcode-detector-polyfill (ZBar WASM) on Apple devices - Fall back through progressively looser getUserMedia constraints - Wait for video metadata/playback before decoding frames - Throttle native scans on iOS and tune ZXing retry intervals - Defer scanner startup until the modal video element is mounted Co-authored-by: Ned Halksworth <hello@nedhalksworth.com>
This commit is contained in:
Generated
+28
-39
@@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@undecaf/barcode-detector-polyfill": "^0.9.23",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"@zxing/browser": "^0.2.0",
|
"@zxing/browser": "^0.2.0",
|
||||||
"appwrite": "^25.0.0",
|
"appwrite": "^25.0.0",
|
||||||
@@ -1160,9 +1161,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1176,9 +1174,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1192,9 +1187,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1208,9 +1200,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1224,9 +1213,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1240,9 +1226,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1256,9 +1239,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1272,9 +1252,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1288,9 +1265,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1304,9 +1278,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1320,9 +1291,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1336,9 +1304,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1352,9 +1317,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1880,6 +1842,24 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@undecaf/barcode-detector-polyfill": {
|
||||||
|
"version": "0.9.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/@undecaf/barcode-detector-polyfill/-/barcode-detector-polyfill-0.9.23.tgz",
|
||||||
|
"integrity": "sha512-qVr7jSUbE5a30X9dByDym2NzsqyH+MFwyFiu4QSHDQMLCImTJj/et7pEcOtGqlL4UB5J6J3d0hK4/5d4MMowYA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@undecaf/zbar-wasm": "^0.9.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@undecaf/zbar-wasm": {
|
||||||
|
"version": "0.9.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@undecaf/zbar-wasm/-/zbar-wasm-0.9.16.tgz",
|
||||||
|
"integrity": "sha512-T5PcT6g+tLScGjR4WmnRErNvfKqEc3kRg2ux14wHmIDNbvNeXa0BkFK19PRK/jb6zGy5NyWtn4ko6KeNuZc/fQ==",
|
||||||
|
"license": "LGPL-2.1+",
|
||||||
|
"dependencies": {
|
||||||
|
"jschardet": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||||
@@ -3622,6 +3602,15 @@
|
|||||||
"js-yaml": "bin/js-yaml.js"
|
"js-yaml": "bin/js-yaml.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jschardet": {
|
||||||
|
"version": "3.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/jschardet/-/jschardet-3.1.4.tgz",
|
||||||
|
"integrity": "sha512-/kmVISmrwVwtyYU40iQUOp3SUPk2dhNCMsZBQX0R1/jZ8maaXJ/oZIzUOiyOqcgtLnETFKYChbJ5iDC/eWmFHg==",
|
||||||
|
"license": "LGPL-2.1+",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.1.90"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jsesc": {
|
"node_modules/jsesc": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"setup:appwrite": "node scripts/setup-appwrite.mjs"
|
"setup:appwrite": "node scripts/setup-appwrite.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@undecaf/barcode-detector-polyfill": "^0.9.23",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"@zxing/browser": "^0.2.0",
|
"@zxing/browser": "^0.2.0",
|
||||||
"appwrite": "^25.0.0",
|
"appwrite": "^25.0.0",
|
||||||
|
|||||||
@@ -191,8 +191,11 @@ export function BarcodeScannerModal({
|
|||||||
window.setTimeout(() => closeButtonRef.current?.focus(), 80);
|
window.setTimeout(() => closeButtonRef.current?.focus(), 80);
|
||||||
|
|
||||||
let active = true;
|
let active = true;
|
||||||
|
let frameId = 0;
|
||||||
|
|
||||||
|
const startScanner = () => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!video) return undefined;
|
if (!video || !active) return;
|
||||||
|
|
||||||
void startBarcodeScanner(video, handleScannerResult, handleScannerError)
|
void startBarcodeScanner(video, handleScannerResult, handleScannerError)
|
||||||
.then((controller) => {
|
.then((controller) => {
|
||||||
@@ -209,6 +212,11 @@ export function BarcodeScannerModal({
|
|||||||
setScannerError(error);
|
setScannerError(error);
|
||||||
setPhase("error");
|
setPhase("error");
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
frameId = window.requestAnimationFrame(() => {
|
||||||
|
window.requestAnimationFrame(startScanner);
|
||||||
|
});
|
||||||
|
|
||||||
void listBarcodeCatalog()
|
void listBarcodeCatalog()
|
||||||
.then((catalog) => {
|
.then((catalog) => {
|
||||||
@@ -224,6 +232,7 @@ export function BarcodeScannerModal({
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
|
window.cancelAnimationFrame(frameId);
|
||||||
stopScanner();
|
stopScanner();
|
||||||
};
|
};
|
||||||
}, [applyManualDefaults, handleScannerError, handleScannerResult, open, stopScanner, userId]);
|
}, [applyManualDefaults, handleScannerError, handleScannerResult, open, stopScanner, userId]);
|
||||||
@@ -335,6 +344,7 @@ export function BarcodeScannerModal({
|
|||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
className="aspect-[3/4] w-full bg-black object-cover sm:aspect-video"
|
className="aspect-[3/4] w-full bg-black object-cover sm:aspect-video"
|
||||||
|
autoPlay
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
aria-label="Live camera preview"
|
aria-label="Live camera preview"
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { BarcodeDetectorPolyfill } from "@undecaf/barcode-detector-polyfill";
|
||||||
|
|
||||||
|
type WindowWithBarcodeDetector = Window & {
|
||||||
|
BarcodeDetector?: typeof BarcodeDetectorPolyfill;
|
||||||
|
};
|
||||||
|
|
||||||
|
let detectorReady: Promise<void> | null = null;
|
||||||
|
|
||||||
|
export function isAppleMobileDevice() {
|
||||||
|
if (typeof navigator === "undefined") return false;
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
return /iPad|iPhone|iPod/i.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureBarcodeDetector() {
|
||||||
|
if (detectorReady) return detectorReady;
|
||||||
|
|
||||||
|
detectorReady = (async () => {
|
||||||
|
const globalWindow = window as WindowWithBarcodeDetector;
|
||||||
|
const shouldForcePolyfill = isAppleMobileDevice();
|
||||||
|
|
||||||
|
if (shouldForcePolyfill) {
|
||||||
|
globalWindow.BarcodeDetector = BarcodeDetectorPolyfill;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await globalWindow.BarcodeDetector?.getSupportedFormats();
|
||||||
|
} catch {
|
||||||
|
globalWindow.BarcodeDetector = BarcodeDetectorPolyfill;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return detectorReady;
|
||||||
|
}
|
||||||
+101
-17
@@ -4,6 +4,7 @@ import {
|
|||||||
BrowserMultiFormatReader,
|
BrowserMultiFormatReader,
|
||||||
type IScannerControls,
|
type IScannerControls,
|
||||||
} from "@zxing/browser";
|
} from "@zxing/browser";
|
||||||
|
import { ensureBarcodeDetector, isAppleMobileDevice } from "./barcodeDetectorSupport";
|
||||||
import { normalizeBarcode } from "./barcodeLookup";
|
import { normalizeBarcode } from "./barcodeLookup";
|
||||||
|
|
||||||
export type BarcodeScannerErrorCode =
|
export type BarcodeScannerErrorCode =
|
||||||
@@ -54,7 +55,7 @@ const ZXING_FORMATS = [
|
|||||||
BarcodeFormat.UPC_A,
|
BarcodeFormat.UPC_A,
|
||||||
BarcodeFormat.UPC_E,
|
BarcodeFormat.UPC_E,
|
||||||
];
|
];
|
||||||
const SCAN_CONSTRAINTS: MediaStreamConstraints = {
|
const PREFERRED_SCAN_CONSTRAINTS: MediaStreamConstraints = {
|
||||||
video: {
|
video: {
|
||||||
facingMode: { ideal: "environment" },
|
facingMode: { ideal: "environment" },
|
||||||
width: { ideal: 1280 },
|
width: { ideal: 1280 },
|
||||||
@@ -62,6 +63,7 @@ const SCAN_CONSTRAINTS: MediaStreamConstraints = {
|
|||||||
},
|
},
|
||||||
audio: false,
|
audio: false,
|
||||||
};
|
};
|
||||||
|
const IOS_NATIVE_SCAN_INTERVAL_MS = 150;
|
||||||
|
|
||||||
export async function startBarcodeScanner(
|
export async function startBarcodeScanner(
|
||||||
videoElement: HTMLVideoElement,
|
videoElement: HTMLVideoElement,
|
||||||
@@ -72,6 +74,8 @@ export async function startBarcodeScanner(
|
|||||||
throw toScannerError(new Error("Camera access is not supported in this browser."));
|
throw toScannerError(new Error("Camera access is not supported in this browser."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ensureBarcodeDetector();
|
||||||
|
|
||||||
if (await supportsNativeBarcodeDetector()) {
|
if (await supportsNativeBarcodeDetector()) {
|
||||||
try {
|
try {
|
||||||
return await startNativeBarcodeScanner(videoElement, onResult);
|
return await startNativeBarcodeScanner(videoElement, onResult);
|
||||||
@@ -121,15 +125,14 @@ function startNativeBarcodeScanner(
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
let animationFrame = 0;
|
let animationFrame = 0;
|
||||||
|
let scanInterval = 0;
|
||||||
let stream: MediaStream | null = null;
|
let stream: MediaStream | null = null;
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
try {
|
try {
|
||||||
stream = await navigator.mediaDevices.getUserMedia(SCAN_CONSTRAINTS);
|
stream = await getCameraStream();
|
||||||
videoElement.srcObject = stream;
|
prepareVideoElement(videoElement, stream);
|
||||||
videoElement.setAttribute("playsinline", "true");
|
await waitForVideoReady(videoElement);
|
||||||
videoElement.muted = true;
|
|
||||||
await videoElement.play();
|
|
||||||
|
|
||||||
const Detector = (window as WindowWithBarcodeDetector).BarcodeDetector;
|
const Detector = (window as WindowWithBarcodeDetector).BarcodeDetector;
|
||||||
if (!Detector) {
|
if (!Detector) {
|
||||||
@@ -140,13 +143,14 @@ function startNativeBarcodeScanner(
|
|||||||
const stop = () => {
|
const stop = () => {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
window.cancelAnimationFrame(animationFrame);
|
window.cancelAnimationFrame(animationFrame);
|
||||||
|
window.clearInterval(scanInterval);
|
||||||
stopVideoStream(videoElement);
|
stopVideoStream(videoElement);
|
||||||
};
|
};
|
||||||
|
|
||||||
const scan = async () => {
|
const scan = async () => {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
try {
|
try {
|
||||||
if (videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
if (videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && videoElement.videoWidth > 0) {
|
||||||
const barcodes = await detector.detect(videoElement);
|
const barcodes = await detector.detect(videoElement);
|
||||||
const barcode = barcodes.find((item) => normalizeBarcode(item.rawValue ?? ""));
|
const barcode = barcodes.find((item) => normalizeBarcode(item.rawValue ?? ""));
|
||||||
if (barcode?.rawValue) {
|
if (barcode?.rawValue) {
|
||||||
@@ -156,12 +160,24 @@ function startNativeBarcodeScanner(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} catch {
|
||||||
if (!stopped) animationFrame = window.requestAnimationFrame(() => void scan());
|
// Keep scanning; transient frame errors are common on mobile Safari.
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
animationFrame = window.requestAnimationFrame(() => void scan());
|
const scheduleScan = () => {
|
||||||
|
if (stopped) return;
|
||||||
|
void scan().finally(() => {
|
||||||
|
if (!stopped) animationFrame = window.requestAnimationFrame(scheduleScan);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isAppleMobileDevice()) {
|
||||||
|
scanInterval = window.setInterval(() => void scan(), IOS_NATIVE_SCAN_INTERVAL_MS);
|
||||||
|
} else {
|
||||||
|
animationFrame = window.requestAnimationFrame(scheduleScan);
|
||||||
|
}
|
||||||
|
|
||||||
resolve({ mode: "native", stop });
|
resolve({ mode: "native", stop });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (stream) stream.getTracks().forEach((track) => track.stop());
|
if (stream) stream.getTracks().forEach((track) => track.stop());
|
||||||
@@ -178,14 +194,17 @@ async function startZxingBarcodeScanner(
|
|||||||
onResult: (result: BarcodeScanResult) => void,
|
onResult: (result: BarcodeScanResult) => void,
|
||||||
onError: (error: BarcodeScannerError) => void,
|
onError: (error: BarcodeScannerError) => void,
|
||||||
): Promise<BarcodeScannerController> {
|
): Promise<BarcodeScannerController> {
|
||||||
const reader = new BrowserMultiFormatReader();
|
const reader = new BrowserMultiFormatReader(undefined, {
|
||||||
|
delayBetweenScanAttempts: isAppleMobileDevice() ? 150 : 500,
|
||||||
|
});
|
||||||
reader.possibleFormats = ZXING_FORMATS;
|
reader.possibleFormats = ZXING_FORMATS;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const controls = await reader.decodeFromConstraints(
|
const stream = await getCameraStream();
|
||||||
SCAN_CONSTRAINTS,
|
prepareVideoElement(videoElement, stream);
|
||||||
videoElement,
|
await waitForVideoReady(videoElement);
|
||||||
(result, error) => {
|
|
||||||
|
const controls = await reader.decodeFromStream(stream, videoElement, (result, error) => {
|
||||||
if (result) {
|
if (result) {
|
||||||
onResult({
|
onResult({
|
||||||
value: normalizeBarcode(result.getText()),
|
value: normalizeBarcode(result.getText()),
|
||||||
@@ -196,8 +215,7 @@ async function startZxingBarcodeScanner(
|
|||||||
if (error && !/not.?found/i.test(error.name) && !/not.?found/i.test(error.message)) {
|
if (error && !/not.?found/i.test(error.name) && !/not.?found/i.test(error.message)) {
|
||||||
onError(toScannerError(error));
|
onError(toScannerError(error));
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mode: "zxing",
|
mode: "zxing",
|
||||||
@@ -229,6 +247,72 @@ async function supportsNativeBarcodeDetector() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getCameraStream() {
|
||||||
|
const attempts: MediaStreamConstraints[] = [
|
||||||
|
PREFERRED_SCAN_CONSTRAINTS,
|
||||||
|
{ video: { facingMode: { ideal: "environment" } }, audio: false },
|
||||||
|
{ video: { facingMode: "environment" }, audio: false },
|
||||||
|
{ video: true, audio: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
let lastError: unknown;
|
||||||
|
for (const constraints of attempts) {
|
||||||
|
try {
|
||||||
|
return await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (isCameraAccessError(error) && !(error instanceof DOMException && error.name === "OverconstrainedError")) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError ?? new Error("Could not access the camera.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareVideoElement(videoElement: HTMLVideoElement, stream: MediaStream) {
|
||||||
|
videoElement.srcObject = stream;
|
||||||
|
videoElement.setAttribute("playsinline", "true");
|
||||||
|
videoElement.setAttribute("webkit-playsinline", "true");
|
||||||
|
videoElement.setAttribute("autoplay", "true");
|
||||||
|
videoElement.muted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForVideoReady(videoElement: HTMLVideoElement) {
|
||||||
|
if (videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && videoElement.videoWidth > 0) {
|
||||||
|
await playVideoElement(videoElement);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const onReady = () => {
|
||||||
|
cleanup();
|
||||||
|
void playVideoElement(videoElement).then(resolve).catch(reject);
|
||||||
|
};
|
||||||
|
const onError = () => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("Camera preview failed to start."));
|
||||||
|
};
|
||||||
|
const cleanup = () => {
|
||||||
|
videoElement.removeEventListener("loadedmetadata", onReady);
|
||||||
|
videoElement.removeEventListener("error", onError);
|
||||||
|
};
|
||||||
|
|
||||||
|
videoElement.addEventListener("loadedmetadata", onReady, { once: true });
|
||||||
|
videoElement.addEventListener("error", onError, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playVideoElement(videoElement: HTMLVideoElement) {
|
||||||
|
try {
|
||||||
|
await videoElement.play();
|
||||||
|
} catch (error) {
|
||||||
|
if (videoElement.paused) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isCameraAccessError(error: unknown) {
|
function isCameraAccessError(error: unknown) {
|
||||||
if (!(error instanceof DOMException)) return false;
|
if (!(error instanceof DOMException)) return false;
|
||||||
return ["NotAllowedError", "NotFoundError", "NotReadableError", "OverconstrainedError"].includes(error.name);
|
return ["NotAllowedError", "NotFoundError", "NotReadableError", "OverconstrainedError"].includes(error.name);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export default defineConfig(({ command }) => ({
|
|||||||
charts: ["recharts"],
|
charts: ["recharts"],
|
||||||
motion: ["framer-motion"],
|
motion: ["framer-motion"],
|
||||||
icons: ["lucide-react"],
|
icons: ["lucide-react"],
|
||||||
|
barcode: ["@undecaf/barcode-detector-polyfill"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user