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:
@@ -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<string | null>(null)
|
||||
const [ready, setReady] = 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 [previewBlob, setPreviewBlob] = useState<Blob | null>(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"
|
||||
/>
|
||||
</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">
|
||||
<p className="live-log-modal-hint">{t('logs.live_photo_native_hint')}</p>
|
||||
<button
|
||||
@@ -256,7 +282,7 @@ export default function LiveCameraCapture({
|
||||
{t('logs.live_photo_open_camera_btn')}
|
||||
</button>
|
||||
</div>
|
||||
) : cameraError && !ready ? null : (
|
||||
) : phase === 'live' && !cameraError ? (
|
||||
<div className="live-camera-preview-wrap">
|
||||
<video
|
||||
ref={videoRef}
|
||||
@@ -269,7 +295,7 @@ export default function LiveCameraCapture({
|
||||
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{onCaptionChange && (
|
||||
<div className="input-group live-camera-caption">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user