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