diff --git a/client/src/App.css b/client/src/App.css index e334014..68a251b 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -3607,6 +3607,44 @@ html.theme-cupertino .events-scroll-container { 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 { margin-top: 16px; } diff --git a/client/src/components/LiveCameraCapture.tsx b/client/src/components/LiveCameraCapture.tsx index 32d1c22..1733e9a 100644 --- a/client/src/components/LiveCameraCapture.tsx +++ b/client/src/components/LiveCameraCapture.tsx @@ -1,6 +1,10 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Camera, X } from 'lucide-react' +import { + captureVideoFrame, + preferNativeCameraPicker +} from '../utils/captureVideoFrame.js' interface LiveCameraCaptureProps { open: boolean @@ -11,6 +15,8 @@ interface LiveCameraCaptureProps { onCapture: (blob: Blob) => void } +type Phase = 'live' | 'preview' | 'native' + export default function LiveCameraCapture({ open, busy = false, @@ -21,9 +27,26 @@ export default function LiveCameraCapture({ }: LiveCameraCaptureProps) { const { t } = useTranslation() const videoRef = useRef(null) + const fileInputRef = useRef(null) const streamRef = useRef(null) + const previewUrlRef = useRef(null) + const [cameraError, setCameraError] = useState(null) const [ready, setReady] = useState(false) + const [capturing, setCapturing] = useState(false) + const [phase, setPhase] = useState(() => (preferNativeCameraPicker() ? 'native' : 'live')) + const [previewUrl, setPreviewUrl] = useState(null) + const [previewBlob, setPreviewBlob] = useState(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(() => { for (const track of streamRef.current?.getTracks() ?? []) { @@ -36,10 +59,44 @@ export default function LiveCameraCapture({ 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(() => { if (!open) { stopStream() + clearPreview() setCameraError(null) + setCapturing(false) + setPhase(preferNativeCameraPicker() ? 'native' : 'live') + return + } + setPhase(preferNativeCameraPicker() ? 'native' : 'live') + clearPreview() + }, [open, stopStream, clearPreview]) + + useEffect(() => { + if (!open || phase !== 'live') { + stopStream() return } @@ -68,11 +125,19 @@ export default function LiveCameraCapture({ } streamRef.current = stream const video = videoRef.current - if (video) { - video.srcObject = stream - await video.play() - setReady(true) + if (!video) return + + const markReady = () => { + if (cancelled) return + if (video.videoWidth > 0 && video.videoHeight > 0) { + setReady(true) + } } + + video.onloadedmetadata = markReady + video.srcObject = stream + await video.play() + markReady() } catch (err) { console.error('Camera access failed:', err) if (!cancelled) { @@ -86,34 +151,58 @@ export default function LiveCameraCapture({ cancelled = true stopStream() } - }, [open, stopStream, t]) + }, [open, phase, streamGeneration, stopStream, t]) - const handleCapture = () => { + const handleCapture = async () => { const video = videoRef.current - if (!video || !ready || busy) return + if (!video || !ready || busy || capturing) return - const width = video.videoWidth - const height = video.videoHeight - if (!width || !height) return + setCapturing(true) + setCameraError(null) + 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') - canvas.width = width - canvas.height = height - const ctx = canvas.getContext('2d') - if (!ctx) return - ctx.drawImage(video, 0, 0, width, height) + const handleNativeFile = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + e.target.value = '' + if (!file || busy) return - canvas.toBlob( - (blob) => { - if (blob) onCapture(blob) - }, - 'image/jpeg', - 0.92 - ) + setCameraError(null) + try { + enterPreview(file) + } catch (err) { + console.error('Live camera file pick failed:', err) + 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 + const showPreview = phase === 'preview' && previewUrl + return (
- {cameraError ? ( + void handleNativeFile(e)} + /> + + {cameraError && (

{cameraError}

- ) : ( + )} + + {showPreview ? ( +
+ +
+ ) : phase === 'native' ? ( +
+

{t('logs.live_photo_native_hint')}

+ +
+ ) : cameraError && !ready ? null : (
diff --git a/client/src/i18n/locales/da.json b/client/src/i18n/locales/da.json index 06ca2ca..a65ebd1 100644 --- a/client/src/i18n/locales/da.json +++ b/client/src/i18n/locales/da.json @@ -235,6 +235,11 @@ "live_fix_lng_placeholder": "Længde (Lng)", "live_photo_btn": "Foto (kamera)", "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_denied": "Kameraadgang nægtet eller utilgængelig.", "live_photo_camera_unavailable": "Kamera understøttes ikke i denne browser.", diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 8bd6ae2..188b130 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -235,6 +235,11 @@ "live_fix_lng_placeholder": "Länge (Lng)", "live_photo_btn": "Foto (Kamera)", "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_denied": "Kamerazugriff verweigert oder nicht verfügbar.", "live_photo_camera_unavailable": "Kamera wird von diesem Browser nicht unterstützt.", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 204a2cf..8598c58 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -235,6 +235,11 @@ "live_fix_lng_placeholder": "Longitude (Lng)", "live_photo_btn": "Photo (camera)", "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_denied": "Camera access denied or unavailable.", "live_photo_camera_unavailable": "Camera is not supported in this browser.", diff --git a/client/src/i18n/locales/nb.json b/client/src/i18n/locales/nb.json index 1394cc6..6a5fb7f 100644 --- a/client/src/i18n/locales/nb.json +++ b/client/src/i18n/locales/nb.json @@ -235,6 +235,11 @@ "live_fix_lng_placeholder": "Lengde (Lng)", "live_photo_btn": "Foto (kamera)", "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_denied": "Kameratilgang nektet eller utilgjengelig.", "live_photo_camera_unavailable": "Kamera støttes ikke i denne nettleseren.", diff --git a/client/src/i18n/locales/sv.json b/client/src/i18n/locales/sv.json index 07fee46..5025296 100644 --- a/client/src/i18n/locales/sv.json +++ b/client/src/i18n/locales/sv.json @@ -235,6 +235,11 @@ "live_fix_lng_placeholder": "Longitud (Lng)", "live_photo_btn": "Foto (kamera)", "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_denied": "Kameraåtkomst nekad eller ej tillgänglig.", "live_photo_camera_unavailable": "Kameran stöds inte i den här webbläsaren.", diff --git a/client/src/utils/captureVideoFrame.test.ts b/client/src/utils/captureVideoFrame.test.ts new file mode 100644 index 0000000..d31d330 --- /dev/null +++ b/client/src/utils/captureVideoFrame.test.ts @@ -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') + }) +}) diff --git a/client/src/utils/captureVideoFrame.ts b/client/src/utils/captureVideoFrame.ts new file mode 100644 index 0000000..45701d7 --- /dev/null +++ b/client/src/utils/captureVideoFrame.ts @@ -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 { + 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 { + 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) +}