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 { 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">
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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