diff --git a/src/hooks/useCamera.test.ts b/src/hooks/useCamera.test.ts index 87f6fea..75aa6b4 100644 --- a/src/hooks/useCamera.test.ts +++ b/src/hooks/useCamera.test.ts @@ -63,4 +63,32 @@ describe('useCamera', () => { await act(async () => { await result.current.switchCamera(); }); expect(result.current.facingMode).toBe('user'); }); + + it('releases the current camera before opening the next (no device-busy error)', async () => { + const track1 = { stop: vi.fn() }; + const gum = vi.fn().mockImplementation(() => Promise.resolve({ getTracks: () => [track1] })); + mockMediaDevices({ getUserMedia: gum }); + const { result } = renderHook(() => useCamera()); + await act(async () => { await result.current.start(); }); + await act(async () => { await result.current.switchCamera(); }); + // The first stream's tracks must be stopped as part of switching. + expect(track1.stop).toHaveBeenCalled(); + }); + + it('recovers to the previous camera when the switch fails', async () => { + let call = 0; + const gum = vi.fn().mockImplementation(() => { + call += 1; + if (call === 2) return Promise.reject(Object.assign(new Error('busy'), { name: 'NotReadableError' })); + return Promise.resolve(new FakeStream()); + }); + mockMediaDevices({ getUserMedia: gum }); + const { result } = renderHook(() => useCamera()); + await act(async () => { await result.current.start(); }); // env ok + await act(async () => { await result.current.switchCamera(); }); // user fails -> revert to env + expect(result.current.facingMode).toBe('environment'); + expect(result.current.stream).not.toBeNull(); + expect(result.current.error).toBeNull(); + expect(gum).toHaveBeenCalledTimes(3); + }); }); diff --git a/src/hooks/useCamera.ts b/src/hooks/useCamera.ts index a149d9b..9556174 100644 --- a/src/hooks/useCamera.ts +++ b/src/hooks/useCamera.ts @@ -32,19 +32,23 @@ export function useCamera() { setStream(null); }, []); - const open = useCallback(async (mode: 'environment' | 'user') => { + const open = useCallback(async (mode: 'environment' | 'user'): Promise => { setError(null); if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) { setError({ reason: 'unsupported', message: MESSAGES.unsupported }); - return; + return false; } if (typeof window !== 'undefined' && window.isSecureContext === false) { setError({ reason: 'insecure', message: MESSAGES.insecure }); - return; + return false; } + // Release the current camera BEFORE requesting another. Some devices/browsers + // only allow one open stream, so requesting a second while the first is live + // throws NotReadableError ("Could not start the camera"). + streamRef.current?.getTracks().forEach((t) => t.stop()); + streamRef.current = null; try { const s = await navigator.mediaDevices.getUserMedia({ video: { facingMode: mode }, audio: false }); - streamRef.current?.getTracks().forEach((t) => t.stop()); streamRef.current = s; setStream(s); setFacingMode(mode); @@ -54,17 +58,24 @@ export function useCamera() { } catch { setHasMultiple(false); } + return true; } catch (err) { + setStream(null); const reason = classify(err); setError({ reason, message: MESSAGES[reason] }); + return false; } }, []); - const start = useCallback(() => open('environment'), [open]); - const switchCamera = useCallback( - () => open(facingMode === 'environment' ? 'user' : 'environment'), - [open, facingMode], - ); + const start = useCallback(async () => { await open('environment'); }, [open]); + + // Try the other camera; if it can't be opened, fall back to the current one so + // the user is never stranded on an error screen with no working camera. + const switchCamera = useCallback(async () => { + const next = facingMode === 'environment' ? 'user' : 'environment'; + const ok = await open(next); + if (!ok) await open(facingMode); + }, [open, facingMode]); // Attach the stream to the