diff --git a/infrastructure/eid-wallet/src/routes/(app)/personal/components/AddPhotoSheet.svelte b/infrastructure/eid-wallet/src/routes/(app)/personal/components/AddPhotoSheet.svelte index f1b70b210..42884163a 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/personal/components/AddPhotoSheet.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/personal/components/AddPhotoSheet.svelte @@ -4,7 +4,11 @@ import { ButtonAction, CameraPermissionDialog } from "$lib/ui"; import BottomSheet from "$lib/ui/BottomSheet/BottomSheet.svelte"; import { createCameraPermissionManager } from "$lib/utils"; import { PERSONAL_BINDING_MAX_LENGTH } from "$lib/utils/personalBinding"; -import { Cancel01Icon, Delete02Icon } from "@hugeicons/core-free-icons"; +import { + CameraRotated01Icon, + Cancel01Icon, + Delete02Icon, +} from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/svelte"; interface IAddPhotoSheetProps { @@ -45,6 +49,15 @@ let canvas = $state(null); let stream: MediaStream | null = null; let galleryInput = $state(null); +// Which camera to use. The back camera is the default; users can flip to +// the front (selfie) camera from the viewfinder. On desktop "environment" +// is advisory and resolves to the only available camera anyway. +let facingMode = $state<"user" | "environment">("environment"); +// Bumped on every (re)start so a slow getUserMedia belonging to a +// superseded request (e.g. a rapid flip) can detect it lost the race +// and release its stream instead of binding a stale one. +let streamToken = 0; + const cameraPermission = createCameraPermissionManager(); const { checkAndRequestPermission, openSettings } = cameraPermission; let showPermissionDialog = $state(false); @@ -86,6 +99,116 @@ function stopStream() { stream = null; } +const BACK_LABEL = /back|rear|environment/i; +const FRONT_LABEL = /front|face|user|selfie/i; + +// The main rear sensor, resolved once and cached — flipping back to it then +// costs a single getUserMedia instead of re-probing every camera. +let backDeviceId: string | undefined; + +// Open a working stream for the requested facing. +// - Front: a plain facingMode constraint is reliable and fast. +// - Back: the default rear device is often a secondary sensor (ultrawide / +// depth) that streams a black frame, so we locate the *main* rear sensor +// by its torch capability (the technique the KYC scanner uses) and cache +// it. The probe stream for the winning camera is reused, so the main +// camera is opened only once. +async function openCameraStream( + facing: "user" | "environment", +): Promise { + if (facing === "user") { + return navigator.mediaDevices.getUserMedia({ + video: { facingMode: "user" }, + }); + } + if (backDeviceId) { + return navigator.mediaDevices.getUserMedia({ + video: { deviceId: { exact: backDeviceId } }, + }); + } + + let cams = (await navigator.mediaDevices.enumerateDevices()).filter( + (d) => d.kind === "videoinput", + ); + // Labels stay empty until the session has been granted a getUserMedia; + // prime with a throwaway stream so we can tell rear cameras from front. + if (cams.length && cams.every((d) => !d.label)) { + try { + const primer = await navigator.mediaDevices.getUserMedia({ + video: true, + }); + for (const t of primer.getTracks()) t.stop(); + cams = (await navigator.mediaDevices.enumerateDevices()).filter( + (d) => d.kind === "videoinput", + ); + } catch { + // Ignore — fall through to whatever labels we have. + } + } + + // Candidate rear cameras: those labelled back, or (labels hidden) + // everything that isn't explicitly a front camera. + const labelledBack = cams.filter((d) => BACK_LABEL.test(d.label)); + const candidates = labelledBack.length + ? labelledBack + : cams.filter((d) => !FRONT_LABEL.test(d.label)); + + // The main rear sensor is the one exposing a torch. Probe each candidate + // and reuse the winner's stream rather than reopening it. + for (const cam of candidates) { + try { + const probe = await navigator.mediaDevices.getUserMedia({ + video: { deviceId: { exact: cam.deviceId } }, + }); + const caps = probe.getVideoTracks()[0]?.getCapabilities?.(); + if (caps && "torch" in caps) { + backDeviceId = cam.deviceId; + return probe; + } + for (const t of probe.getTracks()) t.stop(); + } catch { + // Skip cameras we can't open. + } + } + + // No torch found (desktop, or a webview that hides capabilities): fall + // back to the last rear candidate — conventionally the main camera — or a + // plain facingMode constraint if we couldn't enumerate anything. + const fallback = candidates.at(-1) ?? cams.at(-1); + backDeviceId = fallback?.deviceId; + return navigator.mediaDevices.getUserMedia({ + video: fallback + ? { deviceId: { exact: fallback.deviceId } } + : { facingMode: "environment" }, + }); +} + +// (Re)start the viewfinder stream for the current `facingMode`. Tears down +// any existing stream first so flipping cameras releases the old one. +async function startStream() { + const token = ++streamToken; + stopStream(); + try { + const pending = await openCameraStream(facingMode); + // A superseded request (rapid flip / close) may have moved on while + // we were opening the camera — release the stream if we lost the race + // so we don't leak it or bind a stale one. + if (!isOpen || mode !== "capture" || token !== streamToken) { + for (const track of pending.getTracks()) track.stop(); + return; + } + stream = pending; + if (video) { + video.srcObject = stream; + await video.play(); + } + } catch (err) { + console.error("[personal] camera error", err); + showPermissionDialog = true; + mode = "pick"; + } +} + async function chooseCamera() { pendingSource = "camera"; const granted = await checkAndRequestPermission(); @@ -93,31 +216,17 @@ async function chooseCamera() { showPermissionDialog = true; return; } + // Default to the back camera each time the viewfinder is opened. + facingMode = "environment"; mode = "capture"; // Wait for the + Capture