fix(live-log): Fehlermeldung wenn keine Systemkamera vorhanden ist

Prüft videoinput-Geräte beim Öffnen des Foto-Modals und zeigt eine
klare Meldung statt leerem Kamera-UI; getUserMedia-Fehler differenziert.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-03 17:44:11 +02:00
parent e014e997de
commit 1326045b25
8 changed files with 125 additions and 14 deletions
+40 -14
View File
@@ -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 {
cameraErrorKeyFromDomException,
probeCameraAvailability
} from '../utils/cameraAvailability.js'
import { import {
captureVideoFrame, captureVideoFrame,
preferNativeCameraPicker preferNativeCameraPicker
@@ -15,7 +19,7 @@ interface LiveCameraCaptureProps {
onCapture: (blob: Blob) => void onCapture: (blob: Blob) => void
} }
type Phase = 'live' | 'preview' | 'native' type Phase = 'checking' | 'live' | 'preview' | 'native'
export default function LiveCameraCapture({ export default function LiveCameraCapture({
open, open,
@@ -34,7 +38,7 @@ export default function LiveCameraCapture({
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 [capturing, setCapturing] = useState(false)
const [phase, setPhase] = useState<Phase>(() => (preferNativeCameraPicker() ? 'native' : 'live')) const [phase, setPhase] = useState<Phase>('checking')
const [previewUrl, setPreviewUrl] = useState<string | null>(null) const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null) const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
const [streamGeneration, setStreamGeneration] = useState(0) const [streamGeneration, setStreamGeneration] = useState(0)
@@ -87,12 +91,37 @@ export default function LiveCameraCapture({
clearPreview() clearPreview()
setCameraError(null) setCameraError(null)
setCapturing(false) setCapturing(false)
setPhase(preferNativeCameraPicker() ? 'native' : 'live') setPhase('checking')
return return
} }
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
let cancelled = false
clearPreview() 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(() => { useEffect(() => {
if (!open || phase !== 'live') { if (!open || phase !== 'live') {
@@ -105,11 +134,6 @@ export default function LiveCameraCapture({
const start = async () => { const start = async () => {
setCameraError(null) setCameraError(null)
setReady(false) setReady(false)
if (!navigator.mediaDevices?.getUserMedia) {
setCameraError(t('logs.live_photo_camera_unavailable'))
return
}
try { try {
const stream = await navigator.mediaDevices.getUserMedia({ const stream = await navigator.mediaDevices.getUserMedia({
video: { video: {
@@ -141,7 +165,7 @@ export default function LiveCameraCapture({
} catch (err) { } catch (err) {
console.error('Camera access failed:', err) console.error('Camera access failed:', err)
if (!cancelled) { 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" className="live-camera-preview live-camera-preview-still"
/> />
</div> </div>
) : phase === 'native' ? ( ) : phase === 'checking' && !cameraError ? (
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
) : phase === 'native' && !cameraError ? (
<div className="live-camera-native-prompt"> <div className="live-camera-native-prompt">
<p className="live-log-modal-hint">{t('logs.live_photo_native_hint')}</p> <p className="live-log-modal-hint">{t('logs.live_photo_native_hint')}</p>
<button <button
@@ -256,7 +282,7 @@ export default function LiveCameraCapture({
{t('logs.live_photo_open_camera_btn')} {t('logs.live_photo_open_camera_btn')}
</button> </button>
</div> </div>
) : cameraError && !ready ? null : ( ) : phase === 'live' && !cameraError ? (
<div className="live-camera-preview-wrap"> <div className="live-camera-preview-wrap">
<video <video
ref={videoRef} ref={videoRef}
@@ -269,7 +295,7 @@ export default function LiveCameraCapture({
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p> <p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
)} )}
</div> </div>
)} ) : null}
{onCaptionChange && ( {onCaptionChange && (
<div className="input-group live-camera-caption"> <div className="input-group live-camera-caption">
+1
View File
@@ -266,6 +266,7 @@
"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.",
"live_photo_no_camera": "Der er intet kamera tilgængeligt på denne enhed.",
"live_photo_error": "Foto kunne ikke gemmes.", "live_photo_error": "Foto kunne ikke gemmes.",
"live_photo_entry": "Foto: {{caption}}", "live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto taget", "live_photo_entry_plain": "Foto taget",
+1
View File
@@ -266,6 +266,7 @@
"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.",
"live_photo_no_camera": "Auf diesem Gerät ist keine Kamera verfügbar.",
"live_photo_error": "Foto konnte nicht gespeichert werden.", "live_photo_error": "Foto konnte nicht gespeichert werden.",
"live_photo_entry": "Foto: {{caption}}", "live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto aufgenommen", "live_photo_entry_plain": "Foto aufgenommen",
+1
View File
@@ -266,6 +266,7 @@
"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.",
"live_photo_no_camera": "No camera is available on this device.",
"live_photo_error": "Could not save photo.", "live_photo_error": "Could not save photo.",
"live_photo_entry": "Photo: {{caption}}", "live_photo_entry": "Photo: {{caption}}",
"live_photo_entry_plain": "Photo captured", "live_photo_entry_plain": "Photo captured",
+1
View File
@@ -266,6 +266,7 @@
"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.",
"live_photo_no_camera": "Ingen kamera er tilgjengelig på denne enheten.",
"live_photo_error": "Kunne ikke lagre foto.", "live_photo_error": "Kunne ikke lagre foto.",
"live_photo_entry": "Foto: {{caption}}", "live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto tatt", "live_photo_entry_plain": "Foto tatt",
+1
View File
@@ -266,6 +266,7 @@
"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.",
"live_photo_no_camera": "Ingen kamera finns på den här enheten.",
"live_photo_error": "Foto kunde inte sparas.", "live_photo_error": "Foto kunde inte sparas.",
"live_photo_entry": "Foto: {{caption}}", "live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto taget", "live_photo_entry_plain": "Foto taget",
@@ -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'
)
})
})
+33
View File
@@ -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<CameraAvailability> {
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'
}