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

@ -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;