Fix mobile photo capture fallbacks

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Warunee Tamkoo 2026-05-08 11:13:39 +07:00
parent b11e660bcf
commit 9fde2b3984

View file

@ -89,7 +89,7 @@ const formattedM = ref()
const formattedH = ref()
const formattedHH = ref()
const formattedA = ref()
const clockInterval = ref<number | undefined>()
const clockInterval = ref<ReturnType<typeof setInterval> | undefined>()
/** function อัพเดทเวลา*/
function updateClock() {
@ -118,6 +118,7 @@ const useLocation = ref<string>('') //กรณีเลือกนอกสถ
const fileImg = ref<any>() //
const remark = ref<string>('') //
const checkInId = ref<string>('') //Id check-in
const MIN_CAPTURE_FILE_SIZE_BYTES = 5 * 1024
/**
* funciton เรยกพดละต ดลองต
@ -219,7 +220,123 @@ const availableCameras = ref<any[]>([])
const currentCameraIndex = ref<number>(0)
const currentCameraType = ref<'front' | 'back' | 'unknown'>('unknown')
const intervalId = ref<number | undefined>(undefined) // interval
/**
* 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.
*/
const isIOSDevice: boolean = (() => {
const platform = navigator.platform || ''
const hasTouchPoints = navigator.maxTouchPoints > 1
return (
/iPhone|iPad|iPod/.test(platform) ||
(platform === 'MacIntel' && hasTouchPoints)
)
})()
const isAndroidDevice: boolean = /Android/i.test(navigator.userAgent)
const preferNativePhotoCapture = ref<boolean>(false)
const useNativePhotoCapture = computed(
() => isIOSDevice || preferNativePhotoCapture.value
)
const centeredPreviewImageStyle = Object.freeze({
objectFit: 'cover',
objectPosition: 'center center',
})
/** Ref for the hidden file input used as native camera fallback */
const nativePhotoInput = ref<HTMLInputElement | null>(null)
function clearSelectedPhoto() {
if (img.value) {
URL.revokeObjectURL(img.value)
img.value = undefined
}
fileImg.value = undefined
}
async function canDecodeImageBlob(blob: Blob) {
if (!blob.type.startsWith('image/')) {
return false
}
const imageUrl = URL.createObjectURL(blob)
try {
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const element = new Image()
element.onload = () => {
if (element.naturalWidth > 0 && element.naturalHeight > 0) {
resolve(element)
} else {
reject(new Error('Image has invalid dimensions'))
}
}
element.onerror = () => reject(new Error('Image decode failed'))
element.src = imageUrl
})
return image.naturalWidth > 0 && image.naturalHeight > 0
} catch (error) {
console.error('Error decoding captured image:', error)
return false
} finally {
URL.revokeObjectURL(imageUrl)
}
}
async function isValidCapturedImage(blob: Blob) {
if (blob.size < MIN_CAPTURE_FILE_SIZE_BYTES) {
return false
}
return canDecodeImageBlob(blob)
}
async function assignSelectedPhoto(file: File) {
clearSelectedPhoto()
fileImg.value = file
img.value = URL.createObjectURL(file)
}
function openNativePhotoCapture() {
if (cameraIsOn.value) {
camera.value?.stop()
cameraIsOn.value = false
}
if (nativePhotoInput.value) {
nativePhotoInput.value.value = ''
nativePhotoInput.value.click()
}
}
/**
* Called when the user selects / captures a photo via the native file-input
* fallback. Mirrors the same `img` + `fileImg` state that capturePhoto()
* sets so the upload flow is identical.
*/
async function onNativePhotoSelected(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
if (!(await isValidCapturedImage(file))) {
clearSelectedPhoto()
messageError(
$q,
'',
'ไม่สามารถอ่านไฟล์รูปภาพได้ กรุณาถ่ายรูปใหม่อีกครั้ง'
)
return
}
await assignSelectedPhoto(file)
}
const intervalId = ref<ReturnType<typeof setInterval> | undefined>(undefined)
/**
* เรมจาก onMounted #1 เช status
@ -346,17 +463,34 @@ 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 (useNativePhotoCapture.value) {
openNativePhotoCapture()
return
}
if (!isPermissionCameraDenied.value) {
try {
// change camera device
if (cameraIsOn.value) {
camera.value?.stop()
} else {
// 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'])
if (devices) {
availableCameras.value = devices
await changeCamera()
// Avoid an extra stop/start cycle here; on iOS it can override the initial camera selection.
const activeId = camera.value?.currentDeviceID()
const activeIdx = activeId
? devices.findIndex((d: any) => d.deviceId === activeId)
: -1
currentCameraIndex.value = activeIdx >= 0 ? activeIdx : 0
currentCameraType.value = identifyCameraType(
devices[currentCameraIndex.value]?.label || ''
)
}
}
cameraIsOn.value = !cameraIsOn.value
@ -439,24 +573,30 @@ async function capturePhoto() {
'image/jpeg',
0.8
)
if (!imageBlob) {
if (!imageBlob || !(await isValidCapturedImage(imageBlob))) {
if (isAndroidDevice) {
preferNativePhotoCapture.value = true
camera.value?.stop()
cameraIsOn.value = false
clearSelectedPhoto()
messageError(
$q,
'',
'ไม่สามารถอ่านรูปจากกล้องของอุปกรณ์ได้ กรุณาถ่ายใหม่อีกครั้ง ระบบจะเปลี่ยนไปใช้โหมดถ่ายภาพของอุปกรณ์'
)
return
}
messageError($q, '', 'ไม่สามารถถ่ายรูปได้ กรุณาลองใหม่อีกครั้ง')
return
}
const fileName = `photo_${Date.now()}.jpg`
//
const file = new File([imageBlob], fileName, { type: 'image/jpeg' })
fileImg.value = file
// URL ( memory leak)
if (img.value) {
URL.revokeObjectURL(img.value)
}
await assignSelectedPhoto(file)
//
camera.value?.stop()
const url = URL.createObjectURL(imageBlob)
img.value = url
} catch (error) {
console.error('Error capturing photo:', error)
messageError($q, error, 'ไม่สามารถถ่ายรูปได้ กรุณาลองใหม่อีกครั้ง')
@ -466,10 +606,11 @@ async function capturePhoto() {
/** function เปลี่ยนรูปภาพ*/
async function refreshPhoto() {
try {
// URL ( memory leak)
if (img.value) {
URL.revokeObjectURL(img.value)
img.value = undefined
clearSelectedPhoto()
if (useNativePhotoCapture.value) {
openNativePhotoCapture()
return
}
await camera.value?.start()
} catch (error) {
@ -552,6 +693,22 @@ async function confirm() {
return
}
if (!fileImg.value || !(await isValidCapturedImage(fileImg.value))) {
if (isAndroidDevice) {
preferNativePhotoCapture.value = true
camera.value?.stop()
cameraIsOn.value = false
}
clearSelectedPhoto()
disabledBtn.value = false
messageError(
$q,
'',
'รูปภาพไม่สมบูรณ์ กรุณาถ่ายรูปใหม่อีกครั้งก่อนลงเวลา'
)
return
}
showLoader()
const isLocation = workplace.value === 'in-place' //*true , false
const locationName = workplace.value === 'in-place' ? '' : useLocation.value
@ -655,11 +812,7 @@ async function onClickConfirm() {
try {
showLoader()
cameraIsOn.value = false
// URL ( memory leak)
if (img.value) {
URL.revokeObjectURL(img.value)
img.value = undefined
}
clearSelectedPhoto()
modalTime.value = false
if (!statusCheckin.value) {
statusCheckin.value = true
@ -697,11 +850,7 @@ const inQueue = ref<boolean>(false)
//
function resetCameraAndImage() {
// URL ( memory leak)
if (img.value) {
URL.revokeObjectURL(img.value)
img.value = undefined
}
clearSelectedPhoto()
if (cameraIsOn.value && camera.value) {
camera.value.stop()
cameraIsOn.value = false
@ -735,7 +884,12 @@ const isPermissionCameraDenied = ref<boolean>(false) // ตัวแปรสำ
async function requestCamera() {
try {
await navigator.mediaDevices.getUserMedia({ video: true })
// Probe camera permission with the same front-camera constraint, then release the stream immediately.
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'user' },
audio: false,
})
stream.getTracks().forEach((track) => track.stop())
} catch (err) {
isPermissionCameraDenied.value = true
}
@ -760,7 +914,8 @@ onMounted(async () => {
// privacy
if (privacyStore.isAccepted) {
mapRef.value?.requestLocationPermission()
requestCamera()
// iOS uses the file-input fallback and does not need a getUserMedia probe
if (!useNativePhotoCapture.value) requestCamera()
}
})
@ -802,7 +957,8 @@ watch(
(newVal) => {
if (newVal) {
mapRef.value?.requestLocationPermission()
requestCamera()
// iOS uses the file-input fallback and does not need a getUserMedia probe
if (!useNativePhotoCapture.value) requestCamera()
}
}
)
@ -929,7 +1085,26 @@ watch(
<!-- แสดงรปเมอกด capture -->
<div v-if="img" class="image-container">
<q-img :src="img" class="image-element"></q-img>
<q-img
:src="img"
class="image-element"
:img-style="centeredPreviewImageStyle"
></q-img>
<!-- Native capture: cameraIsOn stays false so show retake here
the refresh button here instead of inside v-if="cameraIsOn" -->
<div
v-if="useNativePhotoCapture"
class="absolute-bottom-right q-ma-md"
>
<q-btn
round
push
icon="refresh"
size="md"
color="negative"
@click="refreshPhoto"
/>
</div>
</div>
<div v-if="cameraIsOn">
@ -1043,7 +1218,27 @@ watch(
<!-- แสดงรปเมอกด capture -->
<div v-if="img" class="image-container">
<q-img :src="img" class="image-element"></q-img>
<q-img
:src="img"
class="image-element"
:img-style="centeredPreviewImageStyle"
></q-img>
<!-- Native capture: cameraIsOn stays false so show retake here
the refresh button here instead of inside v-if="cameraIsOn" -->
<div
v-if="useNativePhotoCapture"
class="absolute-bottom text-subtitle2 text-center q-py-sm"
style="background: #00000021"
>
<q-btn
round
icon="refresh"
size="18px"
style="background: #263238; color: white"
@click="refreshPhoto"
unelevated
/>
</div>
</div>
<div v-if="cameraIsOn">
@ -1497,6 +1692,21 @@ watch(
</q-card-actions>
</q-card>
</q-dialog>
<!--
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.
-->
<input
v-if="useNativePhotoCapture"
ref="nativePhotoInput"
type="file"
accept="image/*"
capture="user"
style="display: none"
@change="onNativePhotoSelected"
/>
</template>
<style scoped>
@ -1533,6 +1743,8 @@ watch(
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 8px;
}
.image-element {