diff --git a/client/src/components/LiveCameraCapture.tsx b/client/src/components/LiveCameraCapture.tsx index bfad442..199d904 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 { + cameraErrorKeyFromDomException, + probeCameraAvailability +} from '../utils/cameraAvailability.js' import { captureVideoFrame, preferNativeCameraPicker @@ -15,7 +19,7 @@ interface LiveCameraCaptureProps { onCapture: (blob: Blob) => void } -type Phase = 'live' | 'preview' | 'native' +type Phase = 'checking' | 'live' | 'preview' | 'native' export default function LiveCameraCapture({ open, @@ -34,7 +38,7 @@ export default function LiveCameraCapture({ const [cameraError, setCameraError] = useState(null) const [ready, setReady] = useState(false) const [capturing, setCapturing] = useState(false) - const [phase, setPhase] = useState(() => (preferNativeCameraPicker() ? 'native' : 'live')) + const [phase, setPhase] = useState('checking') const [previewUrl, setPreviewUrl] = useState(null) const [previewBlob, setPreviewBlob] = useState(null) const [streamGeneration, setStreamGeneration] = useState(0) @@ -87,12 +91,37 @@ export default function LiveCameraCapture({ clearPreview() setCameraError(null) setCapturing(false) - setPhase(preferNativeCameraPicker() ? 'native' : 'live') + setPhase('checking') return } - setPhase(preferNativeCameraPicker() ? 'native' : 'live') + + let cancelled = false clearPreview() - }, [open, stopStream, clearPreview]) + setCameraError(null) + setCapturing(false) + setPhase('checking') + + const probe = async () => { + const availability = await probeCameraAvailability() + if (cancelled) return + + if (availability === 'unsupported') { + setCameraError(t('logs.live_photo_camera_unavailable')) + return + } + if (availability === 'none') { + setCameraError(t('logs.live_photo_no_camera')) + return + } + + setPhase(preferNativeCameraPicker() ? 'native' : 'live') + } + + void probe() + return () => { + cancelled = true + } + }, [open, clearPreview, stopStream, t]) useEffect(() => { if (!open || phase !== 'live') { @@ -105,11 +134,6 @@ export default function LiveCameraCapture({ const start = async () => { setCameraError(null) setReady(false) - if (!navigator.mediaDevices?.getUserMedia) { - setCameraError(t('logs.live_photo_camera_unavailable')) - return - } - try { const stream = await navigator.mediaDevices.getUserMedia({ video: { @@ -141,7 +165,7 @@ export default function LiveCameraCapture({ } catch (err) { console.error('Camera access failed:', err) if (!cancelled) { - setCameraError(t('logs.live_photo_camera_denied')) + setCameraError(t(cameraErrorKeyFromDomException(err))) } } } @@ -243,7 +267,9 @@ export default function LiveCameraCapture({ className="live-camera-preview live-camera-preview-still" /> - ) : phase === 'native' ? ( + ) : phase === 'checking' && !cameraError ? ( +

{t('logs.live_photo_camera_starting')}

+ ) : phase === 'native' && !cameraError ? (

{t('logs.live_photo_native_hint')}

- ) : cameraError && !ready ? null : ( + ) : phase === 'live' && !cameraError ? (
- )} + ) : null} {onCaptionChange && (
diff --git a/client/src/i18n/locales/da.json b/client/src/i18n/locales/da.json index a331150..f4331bb 100644 --- a/client/src/i18n/locales/da.json +++ b/client/src/i18n/locales/da.json @@ -266,6 +266,7 @@ "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.", + "live_photo_no_camera": "Der er intet kamera tilgængeligt på denne enhed.", "live_photo_error": "Foto kunne ikke gemmes.", "live_photo_entry": "Foto: {{caption}}", "live_photo_entry_plain": "Foto taget", diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 65966ac..f0e1d82 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -266,6 +266,7 @@ "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.", + "live_photo_no_camera": "Auf diesem Gerät ist keine Kamera verfügbar.", "live_photo_error": "Foto konnte nicht gespeichert werden.", "live_photo_entry": "Foto: {{caption}}", "live_photo_entry_plain": "Foto aufgenommen", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index ab7b9ad..677be8f 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -266,6 +266,7 @@ "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.", + "live_photo_no_camera": "No camera is available on this device.", "live_photo_error": "Could not save photo.", "live_photo_entry": "Photo: {{caption}}", "live_photo_entry_plain": "Photo captured", diff --git a/client/src/i18n/locales/nb.json b/client/src/i18n/locales/nb.json index e2817b7..053320e 100644 --- a/client/src/i18n/locales/nb.json +++ b/client/src/i18n/locales/nb.json @@ -266,6 +266,7 @@ "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.", + "live_photo_no_camera": "Ingen kamera er tilgjengelig på denne enheten.", "live_photo_error": "Kunne ikke lagre foto.", "live_photo_entry": "Foto: {{caption}}", "live_photo_entry_plain": "Foto tatt", diff --git a/client/src/i18n/locales/sv.json b/client/src/i18n/locales/sv.json index d9ced0e..c0f6d87 100644 --- a/client/src/i18n/locales/sv.json +++ b/client/src/i18n/locales/sv.json @@ -266,6 +266,7 @@ "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.", + "live_photo_no_camera": "Ingen kamera finns på den här enheten.", "live_photo_error": "Foto kunde inte sparas.", "live_photo_entry": "Foto: {{caption}}", "live_photo_entry_plain": "Foto taget", diff --git a/client/src/utils/cameraAvailability.test.ts b/client/src/utils/cameraAvailability.test.ts new file mode 100644 index 0000000..09cd55a --- /dev/null +++ b/client/src/utils/cameraAvailability.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest' +import { + cameraErrorKeyFromDomException, + isCameraApiSupported, + probeCameraAvailability +} from './cameraAvailability.js' + +describe('cameraAvailability', () => { + it('detects missing camera API', () => { + const nav = { mediaDevices: undefined } + vi.stubGlobal('navigator', nav) + expect(isCameraApiSupported()).toBe(false) + vi.unstubAllGlobals() + }) + + it('returns none when no videoinput devices', async () => { + vi.stubGlobal('navigator', { + mediaDevices: { + getUserMedia: vi.fn(), + enumerateDevices: vi.fn().mockResolvedValue([ + { kind: 'audioinput', deviceId: 'a1', label: '', groupId: '' } + ]) + } + }) + await expect(probeCameraAvailability()).resolves.toBe('none') + vi.unstubAllGlobals() + }) + + it('returns available when a videoinput exists', async () => { + vi.stubGlobal('navigator', { + mediaDevices: { + getUserMedia: vi.fn(), + enumerateDevices: vi.fn().mockResolvedValue([ + { kind: 'videoinput', deviceId: 'v1', label: '', groupId: '' } + ]) + } + }) + await expect(probeCameraAvailability()).resolves.toBe('available') + vi.unstubAllGlobals() + }) + + it('maps NotFoundError to no-camera i18n key', () => { + expect(cameraErrorKeyFromDomException(new DOMException('', 'NotFoundError'))).toBe( + 'logs.live_photo_no_camera' + ) + }) +}) diff --git a/client/src/utils/cameraAvailability.ts b/client/src/utils/cameraAvailability.ts new file mode 100644 index 0000000..8ace3d6 --- /dev/null +++ b/client/src/utils/cameraAvailability.ts @@ -0,0 +1,33 @@ +export type CameraAvailability = 'available' | 'none' | 'unsupported' + +/** Whether the browser exposes camera APIs at all. */ +export function isCameraApiSupported(): boolean { + return typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia +} + +/** Best-effort probe for at least one video input device (no permission prompt). */ +export async function probeCameraAvailability(): Promise { + if (!isCameraApiSupported()) return 'unsupported' + if (!navigator.mediaDevices?.enumerateDevices) { + // Cannot list devices; defer to getUserMedia attempt in the capture UI. + return 'available' + } + try { + const devices = await navigator.mediaDevices.enumerateDevices() + if (devices.some((d) => d.kind === 'videoinput')) return 'available' + return 'none' + } catch { + return 'none' + } +} + +export function cameraErrorKeyFromDomException(err: unknown): string { + const name = err instanceof DOMException ? err.name : '' + if (name === 'NotFoundError' || name === 'OverconstrainedError') { + return 'logs.live_photo_no_camera' + } + if (name === 'NotAllowedError' || name === 'NotReadableError' || name === 'SecurityError') { + return 'logs.live_photo_camera_denied' + } + return 'logs.live_photo_camera_unavailable' +}