fix camera ios 18.x check inline page camera
This commit is contained in:
parent
30a8f01441
commit
9e69d33963
5 changed files with 243 additions and 41 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue