fix camera ios 18.x check inline page camera

This commit is contained in:
Warunee Tamkoo 2026-05-08 17:24:36 +07:00
parent 30a8f01441
commit 9e69d33963
5 changed files with 243 additions and 41 deletions

View file

@ -8,3 +8,6 @@
- [Position Orientation Change Fix](issue_position_orientation_change_fix.md) - Fix for position map not displaying on screen orientation changes
- [Active Camera Scope](project_active_camera_scope.md) - Current camera issue work should stay in HomeView.vue, not legacy MapView.vue
- [Front Camera Preview Alignment](issue_front_camera_preview_alignment.md) - Keep popup preview orientation aligned with normalized saved photos
- [iOS Native Photo Mirroring](issue_ios_native_photo_mirroring.md) - Do not flip iOS native-capture photos during normalization
- [iOS Native Camera Popup](issue_ios_native_camera_popup_control.md) - Inline-first on iOS; native activates only after inline fails (prior native-first seed was reverted)

View file

@ -0,0 +1,11 @@
---
name: front_camera_preview_alignment
description: HomeView inline camera flow should show a non-mirrored preview and keep the saved/uploaded image in the same orientation the user saw while shooting
type: project
---
When fixing front-camera capture issues in `HomeView.vue`, keep the in-page preview and the final saved/uploaded image in the same non-mirrored orientation.
**Why:** Users rejected flows where the live in-page preview looked mirrored or differed from the final image. The expected behavior is “what I see while shooting is what gets used.”
**How to apply:** In the inline `simple-vue-camera` flow, avoid extra front-camera mirroring in either preview styling or snapshot normalization. Treat the in-page preview as the canonical orientation for `img`/`fileImg`.

View file

@ -0,0 +1,11 @@
---
name: ios_native_camera_popup_control
description: iOS uses inline camera first (same as Android); native fallback triggers only after inline fails. The prior "native-first on iOS" approach was reverted per product requirement.
type: project
---
In `HomeView.vue`, the inline camera is **always attempted first** on iOS (and every other platform). The hidden `<input type="file" capture="user">` is only activated by `switchToNativePhotoCapture()` / `enableNativePhotoCaptureFallback()` after inline capture is proven to fail.
**Why:** The product requirement is "use inline camera whenever the device supports inline capture; use native device capture only when inline is not supported or fails." iOS Safari supports `getUserMedia` since iOS 14.3, so inline-first is viable. A prior session hard-coded `useNativePhotoCapture = computed(() => isIOSDevice || preferNativePhotoCapture.value)`, bypassing inline on iOS entirely — that line was reverted.
**How to apply:** `preferNativePhotoCapture` must start `false` on all devices. Never seed it with `isIOSDevice`. All failure paths (`openCamera` permission denied, `camera.start()` throw, invalid snapshot blob, invalid image on submit) already call `switchToNativePhotoCapture()` which sets `preferNativePhotoCapture = true` so subsequent taps go directly to native — covering the first-tap fallback automatically.

View file

@ -0,0 +1,11 @@
---
name: ios_native_photo_mirroring
description: Native photo capture on iOS in HomeView should not be horizontally mirrored during normalization
type: project
---
For `HomeView.vue`, native photo capture on iOS should not apply horizontal mirroring during image normalization.
**Why:** The iOS fallback uses the native still-photo picker, and the returned photo can already be in the correct orientation. Applying the same mirror correction used for other front-camera paths flips the image incorrectly on iOS.
**How to apply:** Keep platform-specific normalization for native capture. Do not assume the iOS file-input photo path behaves the same as `simple-vue-camera` snapshots or Android fallback capture.

View file

@ -1,5 +1,12 @@
<script setup lang="ts">
import { ref, reactive, onMounted, watch, onBeforeUnmount, computed } from 'vue'
import {
ref,
reactive,
onMounted,
watch,
onBeforeUnmount,
computed,
} from 'vue'
import { useQuasar } from 'quasar'
import { format } from 'date-fns'
import Camera from 'simple-vue-camera'
@ -19,6 +26,7 @@ const mixin = useCounterMixin()
const { date2Thai, showLoader, hideLoader, messageError, dialogConfirm } = mixin
const $q = useQuasar()
const {
cameraPermissionState,
checkPrivacyAccepted,
syncPermissionStates,
requestCameraPermission,
@ -225,9 +233,8 @@ const currentCameraIndex = ref<number>(0)
const currentCameraType = ref<'front' | 'back' | 'unknown'>('unknown')
/**
* Use the native still-photo picker only on real iOS/iPadOS devices.
* Desktop mobile emulation can spoof the iPhone user-agent, so rely on
* platform + touch capability instead of user-agent alone.
* Detect real iOS/iPadOS devices. Desktop mobile emulation can spoof the
* iPhone user-agent, so rely on platform + touch capability instead.
*/
const isIOSDevice: boolean = (() => {
const platform = navigator.platform || ''
@ -240,10 +247,16 @@ const isIOSDevice: boolean = (() => {
})()
const isAndroidDevice: boolean = /Android/i.test(navigator.userAgent)
/**
* `preferNativePhotoCapture` starts false on every device inline camera is
* always attempted first. It is set to true only by
* `switchToNativePhotoCapture()` / `enableNativePhotoCaptureFallback()` when
* inline capture is proven to be unsupported or has failed. This covers iOS
* Safari too: getUserMedia is supported there since iOS 14.3, so we try
* inline first and fall back gracefully on first failure.
*/
const preferNativePhotoCapture = ref<boolean>(false)
const useNativePhotoCapture = computed(
() => isIOSDevice || preferNativePhotoCapture.value
)
const useNativePhotoCapture = computed(() => preferNativePhotoCapture.value)
const centeredPreviewImageStyle = Object.freeze({
objectFit: 'cover',
objectPosition: 'center center',
@ -251,6 +264,13 @@ const centeredPreviewImageStyle = Object.freeze({
/** Ref for the hidden file input used as native camera fallback */
const nativePhotoInput = ref<HTMLInputElement | null>(null)
const DEFAULT_NORMALIZED_IMAGE_QUALITY = 0.92
interface NormalizePhotoOptions {
fileName?: string
mirrorHorizontally?: boolean
quality?: number
}
function clearSelectedPhoto() {
if (img.value) {
@ -298,6 +318,70 @@ async function isValidCapturedImage(blob: Blob) {
return canDecodeImageBlob(blob)
}
async function loadImageFromBlob(blob: Blob) {
const imageUrl = URL.createObjectURL(blob)
try {
return await new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image()
image.onload = () => resolve(image)
image.onerror = () => reject(new Error('Image load failed'))
image.src = imageUrl
})
} finally {
URL.revokeObjectURL(imageUrl)
}
}
async function normalizePhotoFile(
source: Blob,
options: NormalizePhotoOptions = {}
) {
const fileName =
options.fileName ??
(source instanceof File && source.name
? source.name
: `photo_${Date.now()}.jpg`)
const imageType =
source.type && source.type.startsWith('image/') ? source.type : 'image/jpeg'
if (!options.mirrorHorizontally) {
if (source instanceof File && source.name === fileName) {
return source
}
return new File([source], fileName, { type: imageType })
}
const image = await loadImageFromBlob(source)
const canvas = document.createElement('canvas')
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
const context = canvas.getContext('2d')
if (!context) {
throw new Error('Canvas context unavailable')
}
context.translate(canvas.width, 0)
context.scale(-1, 1)
context.drawImage(image, 0, 0, canvas.width, canvas.height)
const normalizedBlob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(
resolve,
imageType,
options.quality ?? DEFAULT_NORMALIZED_IMAGE_QUALITY
)
})
if (!normalizedBlob) {
throw new Error('Failed to normalize image')
}
return new File([normalizedBlob], fileName, { type: imageType })
}
async function assignSelectedPhoto(file: File) {
clearSelectedPhoto()
fileImg.value = file
@ -316,9 +400,21 @@ function openNativePhotoCapture() {
}
}
function enableNativePhotoCaptureFallback() {
preferNativePhotoCapture.value = true
camera.value?.stop()
cameraIsOn.value = false
clearSelectedPhoto()
}
function switchToNativePhotoCapture() {
enableNativePhotoCaptureFallback()
openNativePhotoCapture()
}
/**
* Called when the user selects / captures a photo via the native file-input
* fallback. Mirrors the same `img` + `fileImg` state that capturePhoto()
* fallback. Keeps the same `img` + `fileImg` state that capturePhoto()
* sets so the upload flow is identical.
*/
async function onNativePhotoSelected(event: Event) {
@ -337,7 +433,22 @@ async function onNativePhotoSelected(event: Event) {
return
}
await assignSelectedPhoto(file)
try {
const normalizedFile = await normalizePhotoFile(file, {
fileName: file.name,
mirrorHorizontally: !isIOSDevice,
})
if (!(await isValidCapturedImage(normalizedFile))) {
throw new Error('Normalized image is invalid')
}
await assignSelectedPhoto(normalizedFile)
} catch (error) {
console.error('Error normalizing native photo:', error)
clearSelectedPhoto()
messageError($q, error, 'ไม่สามารถเตรียมรูปภาพได้ กรุณาถ่ายรูปใหม่อีกครั้ง')
}
}
const intervalId = ref<ReturnType<typeof setInterval> | undefined>(undefined)
@ -460,6 +571,14 @@ function identifyCameraType(label: string): 'front' | 'back' | 'unknown' {
return 'unknown'
}
function resolveCameraType(
label: string,
fallbackType: 'front' | 'back'
): 'front' | 'back' {
const detectedType = identifyCameraType(label)
return detectedType === 'unknown' ? fallbackType : detectedType
}
/** function เปิดกล้อง*/
async function openCamera() {
// privacy
@ -467,9 +586,8 @@ async function openCamera() {
return
}
// iOS Safari fallback: getUserMedia can open native video-recording UI and
// canvas.drawImage may produce a blank blob. Use the hidden file input
// with capture="user" instead this opens the native camera in photo mode.
// If inline capture previously failed on this device, fall back to the
// native still-photo picker instead of reopening the live camera.
if (useNativePhotoCapture.value) {
openNativePhotoCapture()
return
@ -481,8 +599,23 @@ async function openCamera() {
return
}
if (
(isIOSDevice || isAndroidDevice) &&
!useNativePhotoCapture.value &&
(cameraPermissionState.value === 'denied' ||
cameraPermissionState.value === 'unsupported')
) {
switchToNativePhotoCapture()
return
}
const hasCameraPermission = await requestCameraPermission()
if (!hasCameraPermission) {
if ((isIOSDevice || isAndroidDevice) && !useNativePhotoCapture.value) {
switchToNativePhotoCapture()
return
}
messageError(
$q,
'',
@ -492,6 +625,8 @@ async function openCamera() {
}
try {
currentCameraType.value = 'front'
// Keep the initial stream aligned with the Camera component's front-camera constraint.
await camera.value?.start()
const devices: any = await camera.value?.devices(['videoinput'])
@ -504,13 +639,20 @@ async function openCamera() {
? devices.findIndex((d: any) => d.deviceId === activeId)
: -1
currentCameraIndex.value = activeIdx >= 0 ? activeIdx : 0
currentCameraType.value = identifyCameraType(
devices[currentCameraIndex.value]?.label || ''
currentCameraType.value = resolveCameraType(
devices[currentCameraIndex.value]?.label || '',
'front'
)
}
cameraIsOn.value = true
} catch (error) {
console.error('Error opening camera:', error)
if ((isIOSDevice || isAndroidDevice) && !useNativePhotoCapture.value) {
switchToNativePhotoCapture()
return
}
messageError($q, error, 'ไม่สามารถเปิดกล้องได้ กรุณาลองใหม่อีกครั้ง')
}
}
@ -529,9 +671,15 @@ async function changeCamera(targetCameraType?: 'front' | 'back') {
if (devices.length === 1 || !targetCameraType) {
const device = devices[0]
const fallbackType =
targetCameraType ??
(currentCameraType.value === 'back' ? 'back' : 'front')
await camera.value?.changeCamera(device.deviceId)
currentCameraIndex.value = 0
currentCameraType.value = identifyCameraType(device.label || '')
currentCameraType.value = resolveCameraType(
device.label || '',
fallbackType
)
return
}
@ -544,13 +692,19 @@ async function changeCamera(targetCameraType?: 'front' | 'back') {
const targetDevice = matchingCameras[0]
await camera.value?.changeCamera(targetDevice.deviceId)
currentCameraIndex.value = devices.indexOf(targetDevice)
currentCameraType.value = targetCameraType
currentCameraType.value = resolveCameraType(
targetDevice.label || '',
targetCameraType
)
} else {
const nextIndex = (currentCameraIndex.value + 1) % devices.length
const nextDevice = devices[nextIndex]
await camera.value?.changeCamera(nextDevice.deviceId)
currentCameraIndex.value = nextIndex
currentCameraType.value = identifyCameraType(nextDevice.label || '')
currentCameraType.value = resolveCameraType(
nextDevice.label || '',
targetCameraType
)
}
} catch (error) {
console.error('Error switching camera:', error)
@ -566,10 +720,14 @@ async function switchCamera() {
// 2 ()
const targetIndex = currentCameraIndex.value === 0 ? 1 : 0
const targetDevice = availableCameras.value[targetIndex]
const fallbackType = currentCameraType.value === 'back' ? 'front' : 'back'
await camera.value?.changeCamera(targetDevice.deviceId)
currentCameraIndex.value = targetIndex
currentCameraType.value = identifyCameraType(targetDevice.label || '')
currentCameraType.value = resolveCameraType(
targetDevice.label || '',
fallbackType
)
}
/** function ถ่ายรูป*/
@ -581,16 +739,8 @@ async function capturePhoto() {
0.8
)
if (!imageBlob || !(await isValidCapturedImage(imageBlob))) {
if (isAndroidDevice) {
preferNativePhotoCapture.value = true
camera.value?.stop()
cameraIsOn.value = false
clearSelectedPhoto()
messageError(
$q,
'',
'ไม่สามารถอ่านรูปจากกล้องของอุปกรณ์ได้ กรุณาถ่ายใหม่อีกครั้ง ระบบจะเปลี่ยนไปใช้โหมดถ่ายภาพของอุปกรณ์'
)
if (isIOSDevice || isAndroidDevice) {
switchToNativePhotoCapture()
return
}
@ -598,14 +748,28 @@ async function capturePhoto() {
return
}
const fileName = `photo_${Date.now()}.jpg`
//
const file = new File([imageBlob], fileName, { type: 'image/jpeg' })
await assignSelectedPhoto(file)
const normalizedFile = await normalizePhotoFile(imageBlob, {
fileName,
mirrorHorizontally: false,
quality: 0.8,
})
if (!(await isValidCapturedImage(normalizedFile))) {
throw new Error('Normalized image is invalid')
}
await assignSelectedPhoto(normalizedFile)
//
camera.value?.stop()
} catch (error) {
console.error('Error capturing photo:', error)
if ((isIOSDevice || isAndroidDevice) && !useNativePhotoCapture.value) {
switchToNativePhotoCapture()
return
}
messageError($q, error, 'ไม่สามารถถ่ายรูปได้ กรุณาลองใหม่อีกครั้ง')
}
}
@ -701,10 +865,8 @@ async function confirm() {
}
if (!fileImg.value || !(await isValidCapturedImage(fileImg.value))) {
if (isAndroidDevice) {
preferNativePhotoCapture.value = true
camera.value?.stop()
cameraIsOn.value = false
if ((isIOSDevice || isAndroidDevice) && !useNativePhotoCapture.value) {
enableNativePhotoCaptureFallback()
}
clearSelectedPhoto()
disabledBtn.value = false
@ -1069,6 +1231,9 @@ watch(
<Camera
:resolution="{ width: photoWidth, height: photoHeight }"
ref="camera"
:class="[
'camera-preview',
]"
:autoplay="false"
:playsinline="true"
:facingMode="'user'"
@ -1202,6 +1367,9 @@ watch(
height: photoHeight,
}"
ref="camera"
:class="[
'camera-preview',
]"
:autoplay="false"
:playsinline="true"
:facingMode="'user'"
@ -1686,11 +1854,10 @@ watch(
<!--
Native still-image fallback: hidden file input.
Used for iOS by default and for Android only after the live snapshot path
produces an unreadable image, so the user can retake with the device camera.
Activated only after inline camera is proven unsupported or fails on any
platform including iOS. Never used as the first-choice path.
-->
<input
v-if="useNativePhotoCapture"
ref="nativePhotoInput"
type="file"
accept="image/*"
@ -1750,9 +1917,8 @@ watch(
height: 100%;
}
/* iOS-specific video fixes */
.card-container video,
.card-container-xs video {
/* Keep the live camera preview visually aligned with the normalized saved photo. */
.camera-preview :deep(video) {
width: 100%;
height: 100%;
object-fit: cover;