-
Notifications
You must be signed in to change notification settings - Fork 7
feat: added camera rotation in add photo sheet. #1094
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<HTMLCanvasElement | null>(null); | |
| let stream: MediaStream | null = null; | ||
| let galleryInput = $state<HTMLInputElement | null>(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,38 +99,134 @@ 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<MediaStream> { | ||
| 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" }, | ||
| }); | ||
| } | ||
|
Comment on lines
+102
to
+184
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== file context =="
sed -n '80,195p' "infrastructure/eid-wallet/src/routes/(app)/personal/components/AddPhotoSheet.svelte" 2>/dev/null || true
echo
echo "== PR/objective references to platforms =="
rg -n -i "iOS|Apple|iPhone|iPad|Android|desktop|rear|torch|getCapabilities|facingMode" . \
-g '!node_modules' -g '!dist' -g '!build' -g '!coverage' | head -200
echo
echo "== nearby usages of openCameraStream or AddPhotoSheet =="
rg -n "openCameraStream|AddPhotoSheet|enable.*camera|camera" "infrastructure/eid-wallet/src/routes/(app)/personal" 2>/dev/null || trueRepository: MetaState-Prototype-Project/prototype Length of output: 26058 🌐 Web query:
💡 Result: Support for the 'torch' capability in WKWebView on iOS has been an evolving feature in WebKit [1][2]. While standard Web Media Capture APIs often report the 'torch' capability as unsupported or missing when accessed via Citations:
🌐 Web query:
💡 Result: In Tauri 2 iOS applications, you cannot reliably control the device torch directly through the browser-based navigator.mediaDevices API within the WKWebView [1]. While modern versions of WebKit have added experimental support for Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== Tauri deps =="
sed -n '1,90p' infrastructure/eid-wallet/src-tauri/Cargo.toml
jq '.build, .bundle, .plugins, .identifier, .productName' infrastructure/eid-wallet/src-tauri/tauri.conf.json 2>/dev/null | head -220
echo
echo "== mobile capability =="
cat -n infrastructure/eid-wallet/src-tauri/capabilities/mobile.json
echo
echo "== lockfile relevant Tauri versions (concise) =="
rg -n "'(`@tauri-apps/`|Tauri|tauri)" pnpm-lock.yaml | head -160Repository: MetaState-Prototype-Project/prototype Length of output: 5545 🌐 Web query:
💡 Result: Support for the 'torch' constraint in MediaTrackCapabilities was added to WebKit in September 2023 [1]. While specific official Apple documentation detailing the exact release version is sparse, developer reports and technical tracking indicate that the 'torch' constraint became functional in Safari on iOS starting with iOS 17.4 [2]. Before this implementation, the 'torch' constraint was ignored on iOS Safari [3]. Even following its introduction, some users have reported ongoing issues with the consistency of the 'torch' setting, such as instances where getSettings may return stale or unexpected values in later versions, including iOS 18 [4]. To check if it is supported on a specific device or browser instance, developers should inspect the MediaStreamTrack.getCapabilities object to see if 'torch' is present in the returned list of supported constraints [5]. Citations:
Avoid relying on torch detection for iOS rear-camera selection.
🤖 Prompt for AI Agents |
||
|
|
||
| // (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(); | ||
| if (!granted) { | ||
| showPermissionDialog = true; | ||
| return; | ||
| } | ||
| // Default to the back camera each time the viewfinder is opened. | ||
| facingMode = "environment"; | ||
| mode = "capture"; | ||
| // Wait for the <video> element to mount before binding the stream. | ||
| queueMicrotask(async () => { | ||
| try { | ||
| const pending = await navigator.mediaDevices.getUserMedia({ | ||
| video: { facingMode: "environment" }, | ||
| }); | ||
| // The sheet may have been closed while getUserMedia was pending | ||
| // — if mode is no longer "capture" we'd otherwise leak the | ||
| // stream after stopStream() ran with stream still null. | ||
| if (!isOpen || mode !== "capture") { | ||
| 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"; | ||
| } | ||
| }); | ||
| queueMicrotask(startStream); | ||
| } | ||
|
|
||
| // Toggle between front (selfie) and back cameras, restarting the stream. | ||
| function flipCamera() { | ||
| facingMode = facingMode === "user" ? "environment" : "user"; | ||
| startStream(); | ||
| } | ||
|
|
||
| function chooseGallery() { | ||
|
|
@@ -258,6 +367,19 @@ const sheetTitle = $derived(editing ? "Edit photo mark" : "Add photo mark"); | |
| class="absolute inset-0 w-full h-full rounded-2xl object-cover bg-black" | ||
| ></video> | ||
| <canvas bind:this={canvas} class="hidden"></canvas> | ||
| <button | ||
| type="button" | ||
| aria-label="Switch camera" | ||
| class="absolute top-3 right-3 w-10 h-10 rounded-full bg-black/50 flex items-center justify-center active:opacity-70" | ||
| onclick={flipCamera} | ||
| > | ||
| <HugeiconsIcon | ||
| icon={CameraRotated01Icon} | ||
| size={20} | ||
| strokeWidth={2} | ||
| color="white" | ||
| /> | ||
| </button> | ||
| </div> | ||
| <ButtonAction class="w-full" callback={capturePhoto}> | ||
| Capture | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Default camera and preference persistence contradict the linked issue's requirements. The linked issue states the front camera should default on mobile and the selected camera should be maintained throughout the capture flow, but this implementation defaults to and re-forces the back camera on every open.
infrastructure/eid-wallet/src/routes/(app)/personal/components/AddPhotoSheet.svelte#L52-L60: change the initialfacingModedefault (and the doc comment) so front is the default on mobile, reserving"environment"as the fallback for desktop where "user"-facing has no meaning.infrastructure/eid-wallet/src/routes/(app)/personal/components/AddPhotoSheet.svelte#L212-L224: drop the unconditionalfacingMode = "environment"reset inchooseCamera()so a user's prior front/back selection carries over between opens, per the "maintained throughout the capture flow" requirement.📍 Affects 1 file
infrastructure/eid-wallet/src/routes/(app)/personal/components/AddPhotoSheet.svelte#L52-L60(this comment)infrastructure/eid-wallet/src/routes/(app)/personal/components/AddPhotoSheet.svelte#L212-L224🤖 Prompt for AI Agents