Fix mobile photo capture fallbacks
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
b11e660bcf
commit
9fde2b3984
1 changed files with 244 additions and 32 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue