Files
homebox/frontend/components/App/ScannerModal.vue
Robert Eggl 5f9ab577bb fix: request camera permission in ScannerModal (#1113)
* feat: request camera permission in ScannerModal

* chore: simplify source code
2025-12-19 21:47:37 +00:00

195 lines
6.5 KiB
Vue

<template>
<Dialog :dialog-id="DialogID.Scanner">
<DialogScrollContent>
<DialogHeader>
<DialogTitle>{{ t("scanner.title") }}</DialogTitle>
</DialogHeader>
<div>
<div
v-if="errorMessage"
class="mb-5 flex items-center gap-2 rounded-md border border-destructive bg-destructive/10 p-4 text-destructive"
role="alert"
>
<MdiAlertCircleOutline class="text-destructive" />
<span class="text-sm font-medium">{{ errorMessage }}</span>
</div>
<div
v-if="detectedBarcode"
class="mb-5 flex flex-col items-center gap-2 rounded-md border border-accent-foreground bg-accent p-4 text-accent-foreground"
role="alert"
>
<div class="flex">
<MdiBarcode class="mr-2" />
<span class="flex-1 text-center text-sm font-medium">
{{ detectedBarcodeType }} {{ $t("scanner.barcode_detected_message") }}:
<strong>{{ detectedBarcode }}</strong>
</span>
</div>
<ButtonGroup>
<Button :disabled="loading" type="submit" @click="handleButtonClick">
{{ $t("scanner.barcode_fetch_data") }}
</Button>
</ButtonGroup>
</div>
<!-- eslint-disable-next-line tailwindcss/no-custom-classname -->
<video ref="video" class="aspect-video w-full rounded-lg bg-muted shadow" poster="data:image/gif,AAAA" />
<div class="mt-4">
<Select v-model="selectedSource">
<SelectTrigger class="w-full">
<SelectValue :placeholder="t('scanner.select_video_source')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="source in sources" :key="source.deviceId" :value="source.deviceId">
{{ source.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</DialogScrollContent>
</Dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { BarcodeFormat, BrowserMultiFormatReader, NotFoundException } from "@zxing/library";
import { useI18n } from "vue-i18n";
import { DialogID } from "@/components/ui/dialog-provider/utils";
import { Dialog, DialogHeader, DialogScrollContent, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button, ButtonGroup } from "@/components/ui/button";
import MdiBarcode from "~icons/mdi/barcode";
import MdiAlertCircleOutline from "~icons/mdi/alert-circle-outline";
import { useDialog } from "@/components/ui/dialog-provider";
const { t } = useI18n();
const { activeDialog, openDialog, closeDialog } = useDialog();
const open = computed(() => activeDialog && activeDialog.value === DialogID.Scanner);
const sources = ref<MediaDeviceInfo[]>([]);
const selectedSource = ref<string | null>(null);
const loading = ref(false);
const video = ref<HTMLVideoElement>();
const codeReader = new BrowserMultiFormatReader();
const errorMessage = ref<string | null>(null);
const detectedBarcode = ref<string>("");
const detectedBarcodeType = ref<string>("");
const handleError = (error: unknown) => {
console.error("Scanner error:", error);
errorMessage.value = t("scanner.error");
};
const handleButtonClick = () => {
openDialog(DialogID.ProductImport, { params: { barcode: detectedBarcode.value } });
};
const startScanner = async () => {
errorMessage.value = null;
if (!(navigator && navigator.mediaDevices && "enumerateDevices" in navigator.mediaDevices)) {
errorMessage.value = t("scanner.unsupported");
return;
}
try {
// Request camera permission first
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
stream.getTracks().forEach(track => track.stop());
} catch (err: unknown) {
if (err instanceof Error && err.name === "NotAllowedError") {
errorMessage.value = t("scanner.permission_denied");
return;
}
throw err;
}
const devices = await codeReader.listVideoInputDevices();
sources.value = devices;
if (devices.length > 0) {
for (let i = 0; i < devices.length; i++) {
if (devices[i]!.label.toLowerCase().includes("back")) {
selectedSource.value = devices[i]!.deviceId;
}
}
if (!selectedSource.value) {
selectedSource.value = devices[0]!.deviceId;
}
} else {
errorMessage.value = t("scanner.no_sources");
}
} catch (err) {
handleError(err);
}
};
const stopScanner = () => {
codeReader.reset();
sources.value = [];
selectedSource.value = null;
loading.value = false;
};
watch(open, async isOpen => {
if (isOpen) {
detectedBarcode.value = "";
await startScanner();
} else {
stopScanner();
}
});
watch(selectedSource, async newSource => {
if (!open.value || !newSource) return;
codeReader.reset();
try {
await codeReader.decodeFromVideoDevice(newSource, video.value!, (result, err) => {
if (result && !loading.value) {
loading.value = true;
try {
const url = new URL(result.getText());
if (!url.pathname.startsWith("/")) {
throw new Error(t("scanner.invalid_url"));
}
const sanitizedPath = url.pathname.replace(/[^a-zA-Z0-9-_/]/g, "");
closeDialog(DialogID.Scanner);
navigateTo(sanitizedPath);
} catch (err) {
// Check if it's a barcode for a new element
const bcfmt = result.getBarcodeFormat();
switch (bcfmt) {
case BarcodeFormat.EAN_13:
case BarcodeFormat.UPC_A:
case BarcodeFormat.UPC_E:
case BarcodeFormat.UPC_EAN_EXTENSION:
console.info("Barcode detected");
detectedBarcode.value = result.getText();
detectedBarcodeType.value = BarcodeFormat[bcfmt].replaceAll("_", "-");
break;
default:
handleError(err);
}
loading.value = false;
}
}
if (err && !(err instanceof NotFoundException)) {
console.error(err);
handleError(err);
}
});
} catch (err) {
handleError(err);
}
});
onUnmounted(() => {
stopScanner();
});
</script>