fix: live journal camera save on Android
Use native camera picker on mobile, add preview-and-save step, and harden canvas capture with toDataURL fallback when toBlob fails. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3607,6 +3607,44 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.live-camera-file-input {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-camera-preview-still {
|
||||||
|
display: block;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-camera-native-prompt {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-camera-open-native {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-camera-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.stats-event-series-block + .stats-event-series-block {
|
.stats-event-series-block + .stats-event-series-block {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Camera, X } from 'lucide-react'
|
import { Camera, X } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
captureVideoFrame,
|
||||||
|
preferNativeCameraPicker
|
||||||
|
} from '../utils/captureVideoFrame.js'
|
||||||
|
|
||||||
interface LiveCameraCaptureProps {
|
interface LiveCameraCaptureProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -11,6 +15,8 @@ interface LiveCameraCaptureProps {
|
|||||||
onCapture: (blob: Blob) => void
|
onCapture: (blob: Blob) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Phase = 'live' | 'preview' | 'native'
|
||||||
|
|
||||||
export default function LiveCameraCapture({
|
export default function LiveCameraCapture({
|
||||||
open,
|
open,
|
||||||
busy = false,
|
busy = false,
|
||||||
@@ -21,9 +27,26 @@ export default function LiveCameraCapture({
|
|||||||
}: LiveCameraCaptureProps) {
|
}: LiveCameraCaptureProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
const streamRef = useRef<MediaStream | null>(null)
|
const streamRef = useRef<MediaStream | null>(null)
|
||||||
|
const previewUrlRef = useRef<string | null>(null)
|
||||||
|
|
||||||
const [cameraError, setCameraError] = useState<string | null>(null)
|
const [cameraError, setCameraError] = useState<string | null>(null)
|
||||||
const [ready, setReady] = useState(false)
|
const [ready, setReady] = useState(false)
|
||||||
|
const [capturing, setCapturing] = useState(false)
|
||||||
|
const [phase, setPhase] = useState<Phase>(() => (preferNativeCameraPicker() ? 'native' : 'live'))
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||||
|
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
|
||||||
|
const [streamGeneration, setStreamGeneration] = useState(0)
|
||||||
|
|
||||||
|
const clearPreview = useCallback(() => {
|
||||||
|
if (previewUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(previewUrlRef.current)
|
||||||
|
previewUrlRef.current = null
|
||||||
|
}
|
||||||
|
setPreviewUrl(null)
|
||||||
|
setPreviewBlob(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const stopStream = useCallback(() => {
|
const stopStream = useCallback(() => {
|
||||||
for (const track of streamRef.current?.getTracks() ?? []) {
|
for (const track of streamRef.current?.getTracks() ?? []) {
|
||||||
@@ -36,10 +59,44 @@ export default function LiveCameraCapture({
|
|||||||
setReady(false)
|
setReady(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const enterPreview = useCallback((blob: Blob) => {
|
||||||
|
stopStream()
|
||||||
|
clearPreview()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
previewUrlRef.current = url
|
||||||
|
setPreviewBlob(blob)
|
||||||
|
setPreviewUrl(url)
|
||||||
|
setPhase('preview')
|
||||||
|
}, [stopStream, clearPreview])
|
||||||
|
|
||||||
|
const resetToLive = useCallback(() => {
|
||||||
|
clearPreview()
|
||||||
|
setCameraError(null)
|
||||||
|
setCapturing(false)
|
||||||
|
if (preferNativeCameraPicker()) {
|
||||||
|
setPhase('native')
|
||||||
|
} else {
|
||||||
|
setPhase('live')
|
||||||
|
setStreamGeneration((n) => n + 1)
|
||||||
|
}
|
||||||
|
}, [clearPreview])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
stopStream()
|
stopStream()
|
||||||
|
clearPreview()
|
||||||
setCameraError(null)
|
setCameraError(null)
|
||||||
|
setCapturing(false)
|
||||||
|
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
|
||||||
|
clearPreview()
|
||||||
|
}, [open, stopStream, clearPreview])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || phase !== 'live') {
|
||||||
|
stopStream()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,11 +125,19 @@ export default function LiveCameraCapture({
|
|||||||
}
|
}
|
||||||
streamRef.current = stream
|
streamRef.current = stream
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
if (video) {
|
if (!video) return
|
||||||
video.srcObject = stream
|
|
||||||
await video.play()
|
const markReady = () => {
|
||||||
setReady(true)
|
if (cancelled) return
|
||||||
|
if (video.videoWidth > 0 && video.videoHeight > 0) {
|
||||||
|
setReady(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
video.onloadedmetadata = markReady
|
||||||
|
video.srcObject = stream
|
||||||
|
await video.play()
|
||||||
|
markReady()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Camera access failed:', err)
|
console.error('Camera access failed:', err)
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -86,34 +151,58 @@ export default function LiveCameraCapture({
|
|||||||
cancelled = true
|
cancelled = true
|
||||||
stopStream()
|
stopStream()
|
||||||
}
|
}
|
||||||
}, [open, stopStream, t])
|
}, [open, phase, streamGeneration, stopStream, t])
|
||||||
|
|
||||||
const handleCapture = () => {
|
const handleCapture = async () => {
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
if (!video || !ready || busy) return
|
if (!video || !ready || busy || capturing) return
|
||||||
|
|
||||||
const width = video.videoWidth
|
setCapturing(true)
|
||||||
const height = video.videoHeight
|
setCameraError(null)
|
||||||
if (!width || !height) return
|
try {
|
||||||
|
const blob = await captureVideoFrame(video)
|
||||||
|
enterPreview(blob)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Live camera capture failed:', err)
|
||||||
|
setCameraError(t('logs.live_photo_capture_failed'))
|
||||||
|
} finally {
|
||||||
|
setCapturing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const canvas = document.createElement('canvas')
|
const handleNativeFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
canvas.width = width
|
const file = e.target.files?.[0]
|
||||||
canvas.height = height
|
e.target.value = ''
|
||||||
const ctx = canvas.getContext('2d')
|
if (!file || busy) return
|
||||||
if (!ctx) return
|
|
||||||
ctx.drawImage(video, 0, 0, width, height)
|
|
||||||
|
|
||||||
canvas.toBlob(
|
setCameraError(null)
|
||||||
(blob) => {
|
try {
|
||||||
if (blob) onCapture(blob)
|
enterPreview(file)
|
||||||
},
|
} catch (err) {
|
||||||
'image/jpeg',
|
console.error('Live camera file pick failed:', err)
|
||||||
0.92
|
setCameraError(t('logs.live_photo_capture_failed'))
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!previewBlob || busy) return
|
||||||
|
onCapture(previewBlob)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRetake = () => {
|
||||||
|
if (busy) return
|
||||||
|
resetToLive()
|
||||||
|
}
|
||||||
|
|
||||||
|
const openNativePicker = () => {
|
||||||
|
if (busy) return
|
||||||
|
fileInputRef.current?.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
|
const showPreview = phase === 'preview' && previewUrl
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="live-log-modal-backdrop live-camera-backdrop"
|
className="live-log-modal-backdrop live-camera-backdrop"
|
||||||
@@ -133,9 +222,41 @@ export default function LiveCameraCapture({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cameraError ? (
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
className="live-camera-file-input"
|
||||||
|
onChange={(e) => void handleNativeFile(e)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{cameraError && (
|
||||||
<p className="live-log-modal-hint auth-error">{cameraError}</p>
|
<p className="live-log-modal-hint auth-error">{cameraError}</p>
|
||||||
) : (
|
)}
|
||||||
|
|
||||||
|
{showPreview ? (
|
||||||
|
<div className="live-camera-preview-wrap">
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt=""
|
||||||
|
className="live-camera-preview live-camera-preview-still"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : phase === 'native' ? (
|
||||||
|
<div className="live-camera-native-prompt">
|
||||||
|
<p className="live-log-modal-hint">{t('logs.live_photo_native_hint')}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary live-camera-open-native"
|
||||||
|
onClick={openNativePicker}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<Camera size={18} />
|
||||||
|
{t('logs.live_photo_open_camera_btn')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : cameraError && !ready ? null : (
|
||||||
<div className="live-camera-preview-wrap">
|
<div className="live-camera-preview-wrap">
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
@@ -168,15 +289,33 @@ export default function LiveCameraCapture({
|
|||||||
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}>
|
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}>
|
||||||
{t('logs.confirm_no')}
|
{t('logs.confirm_no')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
{showPreview ? (
|
||||||
className="btn primary live-camera-shutter"
|
<>
|
||||||
onClick={handleCapture}
|
<button type="button" className="btn secondary" onClick={handleRetake} disabled={busy}>
|
||||||
disabled={busy || !ready || !!cameraError}
|
{t('logs.live_photo_retake_btn')}
|
||||||
>
|
</button>
|
||||||
<Camera size={18} />
|
<button
|
||||||
{busy ? t('logs.photo_processing') : t('logs.live_photo_capture_btn')}
|
type="button"
|
||||||
</button>
|
className="btn primary live-camera-shutter"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={busy || !previewBlob}
|
||||||
|
>
|
||||||
|
<Camera size={18} />
|
||||||
|
{busy ? t('logs.photo_processing') : t('logs.live_photo_save_btn')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : phase === 'native' ? null : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary live-camera-shutter"
|
||||||
|
onClick={() => void handleCapture()}
|
||||||
|
disabled={busy || capturing || !ready || !!cameraError}
|
||||||
|
>
|
||||||
|
<Camera size={18} />
|
||||||
|
{capturing ? t('logs.photo_processing') : t('logs.live_photo_capture_btn')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -235,6 +235,11 @@
|
|||||||
"live_fix_lng_placeholder": "Længde (Lng)",
|
"live_fix_lng_placeholder": "Længde (Lng)",
|
||||||
"live_photo_btn": "Foto (kamera)",
|
"live_photo_btn": "Foto (kamera)",
|
||||||
"live_photo_capture_btn": "Tag billede",
|
"live_photo_capture_btn": "Tag billede",
|
||||||
|
"live_photo_save_btn": "Gem",
|
||||||
|
"live_photo_retake_btn": "Tag igen",
|
||||||
|
"live_photo_capture_failed": "Optagelse mislykkedes. Prøv igen.",
|
||||||
|
"live_photo_open_camera_btn": "Åbn kamera",
|
||||||
|
"live_photo_native_hint": "Tag et foto med enhedens kamera og gem det her bagefter.",
|
||||||
"live_photo_camera_starting": "Starter kamera…",
|
"live_photo_camera_starting": "Starter kamera…",
|
||||||
"live_photo_camera_denied": "Kameraadgang nægtet eller utilgængelig.",
|
"live_photo_camera_denied": "Kameraadgang nægtet eller utilgængelig.",
|
||||||
"live_photo_camera_unavailable": "Kamera understøttes ikke i denne browser.",
|
"live_photo_camera_unavailable": "Kamera understøttes ikke i denne browser.",
|
||||||
|
|||||||
@@ -235,6 +235,11 @@
|
|||||||
"live_fix_lng_placeholder": "Länge (Lng)",
|
"live_fix_lng_placeholder": "Länge (Lng)",
|
||||||
"live_photo_btn": "Foto (Kamera)",
|
"live_photo_btn": "Foto (Kamera)",
|
||||||
"live_photo_capture_btn": "Aufnehmen",
|
"live_photo_capture_btn": "Aufnehmen",
|
||||||
|
"live_photo_save_btn": "Speichern",
|
||||||
|
"live_photo_retake_btn": "Neu aufnehmen",
|
||||||
|
"live_photo_capture_failed": "Aufnahme fehlgeschlagen. Bitte erneut versuchen.",
|
||||||
|
"live_photo_open_camera_btn": "Kamera öffnen",
|
||||||
|
"live_photo_native_hint": "Foto mit der Gerätekamera aufnehmen und anschließend hier speichern.",
|
||||||
"live_photo_camera_starting": "Kamera wird gestartet…",
|
"live_photo_camera_starting": "Kamera wird gestartet…",
|
||||||
"live_photo_camera_denied": "Kamerazugriff verweigert oder nicht verfügbar.",
|
"live_photo_camera_denied": "Kamerazugriff verweigert oder nicht verfügbar.",
|
||||||
"live_photo_camera_unavailable": "Kamera wird von diesem Browser nicht unterstützt.",
|
"live_photo_camera_unavailable": "Kamera wird von diesem Browser nicht unterstützt.",
|
||||||
|
|||||||
@@ -235,6 +235,11 @@
|
|||||||
"live_fix_lng_placeholder": "Longitude (Lng)",
|
"live_fix_lng_placeholder": "Longitude (Lng)",
|
||||||
"live_photo_btn": "Photo (camera)",
|
"live_photo_btn": "Photo (camera)",
|
||||||
"live_photo_capture_btn": "Capture",
|
"live_photo_capture_btn": "Capture",
|
||||||
|
"live_photo_save_btn": "Save",
|
||||||
|
"live_photo_retake_btn": "Retake",
|
||||||
|
"live_photo_capture_failed": "Capture failed. Please try again.",
|
||||||
|
"live_photo_open_camera_btn": "Open camera",
|
||||||
|
"live_photo_native_hint": "Take a photo with your device camera, then save it here.",
|
||||||
"live_photo_camera_starting": "Starting camera…",
|
"live_photo_camera_starting": "Starting camera…",
|
||||||
"live_photo_camera_denied": "Camera access denied or unavailable.",
|
"live_photo_camera_denied": "Camera access denied or unavailable.",
|
||||||
"live_photo_camera_unavailable": "Camera is not supported in this browser.",
|
"live_photo_camera_unavailable": "Camera is not supported in this browser.",
|
||||||
|
|||||||
@@ -235,6 +235,11 @@
|
|||||||
"live_fix_lng_placeholder": "Lengde (Lng)",
|
"live_fix_lng_placeholder": "Lengde (Lng)",
|
||||||
"live_photo_btn": "Foto (kamera)",
|
"live_photo_btn": "Foto (kamera)",
|
||||||
"live_photo_capture_btn": "Ta bilde",
|
"live_photo_capture_btn": "Ta bilde",
|
||||||
|
"live_photo_save_btn": "Lagre",
|
||||||
|
"live_photo_retake_btn": "Ta på nytt",
|
||||||
|
"live_photo_capture_failed": "Opptak mislyktes. Prøv igjen.",
|
||||||
|
"live_photo_open_camera_btn": "Åpne kamera",
|
||||||
|
"live_photo_native_hint": "Ta et bilde med enhetskameraet og lagre det her etterpå.",
|
||||||
"live_photo_camera_starting": "Starter kamera…",
|
"live_photo_camera_starting": "Starter kamera…",
|
||||||
"live_photo_camera_denied": "Kameratilgang nektet eller utilgjengelig.",
|
"live_photo_camera_denied": "Kameratilgang nektet eller utilgjengelig.",
|
||||||
"live_photo_camera_unavailable": "Kamera støttes ikke i denne nettleseren.",
|
"live_photo_camera_unavailable": "Kamera støttes ikke i denne nettleseren.",
|
||||||
|
|||||||
@@ -235,6 +235,11 @@
|
|||||||
"live_fix_lng_placeholder": "Longitud (Lng)",
|
"live_fix_lng_placeholder": "Longitud (Lng)",
|
||||||
"live_photo_btn": "Foto (kamera)",
|
"live_photo_btn": "Foto (kamera)",
|
||||||
"live_photo_capture_btn": "Ta foto",
|
"live_photo_capture_btn": "Ta foto",
|
||||||
|
"live_photo_save_btn": "Spara",
|
||||||
|
"live_photo_retake_btn": "Ta om",
|
||||||
|
"live_photo_capture_failed": "Bildtagning misslyckades. Försök igen.",
|
||||||
|
"live_photo_open_camera_btn": "Öppna kamera",
|
||||||
|
"live_photo_native_hint": "Ta ett foto med enhetens kamera och spara det här efteråt.",
|
||||||
"live_photo_camera_starting": "Startar kamera…",
|
"live_photo_camera_starting": "Startar kamera…",
|
||||||
"live_photo_camera_denied": "Kameraåtkomst nekad eller ej tillgänglig.",
|
"live_photo_camera_denied": "Kameraåtkomst nekad eller ej tillgänglig.",
|
||||||
"live_photo_camera_unavailable": "Kameran stöds inte i den här webbläsaren.",
|
"live_photo_camera_unavailable": "Kameran stöds inte i den här webbläsaren.",
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { captureVideoFrame, preferNativeCameraPicker } from './captureVideoFrame.js'
|
||||||
|
|
||||||
|
describe('preferNativeCameraPicker', () => {
|
||||||
|
it('returns true on Android user agents', () => {
|
||||||
|
vi.stubGlobal('navigator', { ...navigator, userAgent: 'Mozilla/5.0 (Linux; Android 14)' })
|
||||||
|
expect(preferNativeCameraPicker()).toBe(true)
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false on desktop without touch', () => {
|
||||||
|
vi.stubGlobal('navigator', {
|
||||||
|
...navigator,
|
||||||
|
userAgent: 'Mozilla/5.0 (Windows NT 10.0)',
|
||||||
|
maxTouchPoints: 0
|
||||||
|
})
|
||||||
|
vi.stubGlobal('matchMedia', () => ({
|
||||||
|
matches: false,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {}
|
||||||
|
}))
|
||||||
|
Object.defineProperty(window, 'ontouchstart', { value: undefined, configurable: true })
|
||||||
|
expect(preferNativeCameraPicker()).toBe(false)
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('captureVideoFrame', () => {
|
||||||
|
it('throws when video dimensions are zero', async () => {
|
||||||
|
const video = { videoWidth: 0, videoHeight: 0 } as HTMLVideoElement
|
||||||
|
await expect(captureVideoFrame(video)).rejects.toThrow('video_frame_not_ready')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/** Capture current video frame as JPEG blob (with Android-safe fallbacks). */
|
||||||
|
export async function captureVideoFrame(video: HTMLVideoElement, quality = 0.92): Promise<Blob> {
|
||||||
|
const width = video.videoWidth
|
||||||
|
const height = video.videoHeight
|
||||||
|
if (!width || !height) {
|
||||||
|
throw new Error('video_frame_not_ready')
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('canvas_context_unavailable')
|
||||||
|
}
|
||||||
|
ctx.drawImage(video, 0, 0, width, height)
|
||||||
|
|
||||||
|
const blob = await canvasToJpegBlob(canvas, quality)
|
||||||
|
if (blob) return blob
|
||||||
|
|
||||||
|
const dataUrl = canvas.toDataURL('image/jpeg', quality)
|
||||||
|
const response = await fetch(dataUrl)
|
||||||
|
const fallback = await response.blob()
|
||||||
|
if (!fallback.size) {
|
||||||
|
throw new Error('capture_encode_failed')
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasToJpegBlob(canvas: HTMLCanvasElement, quality: number): Promise<Blob | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false
|
||||||
|
const finish = (blob: Blob | null) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
window.clearTimeout(timer)
|
||||||
|
resolve(blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => finish(null), 3000)
|
||||||
|
|
||||||
|
try {
|
||||||
|
canvas.toBlob((blob) => finish(blob), 'image/jpeg', quality)
|
||||||
|
} catch {
|
||||||
|
finish(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mobile: native camera via file input is more reliable than getUserMedia + canvas. */
|
||||||
|
export function preferNativeCameraPicker(): boolean {
|
||||||
|
if (typeof window === 'undefined') return false
|
||||||
|
const ua = navigator.userAgent
|
||||||
|
if (/Android|iPhone|iPad|iPod/i.test(ua)) return true
|
||||||
|
const touch = 'ontouchstart' in window || navigator.maxTouchPoints > 0
|
||||||
|
const coarse = window.matchMedia('(pointer: coarse)').matches
|
||||||
|
const narrow = window.matchMedia('(max-width: 768px)').matches
|
||||||
|
return touch && (coarse || narrow)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user