Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/hooks/useCamera.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
29 changes: 20 additions & 9 deletions src/hooks/useCamera.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,23 @@ export function useCamera() {
setStream(null);
}, []);

const open = useCallback(async (mode: 'environment' | 'user') => {
const open = useCallback(async (mode: 'environment' | 'user'): Promise<boolean> => {
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);
Expand All @@ -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 <video> element whenever it changes.
useEffect(() => {
Expand Down
9 changes: 2 additions & 7 deletions src/islands/image/CameraTool.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { ImageResult } from '@/components/ui/ImageResult';
import { CopyImageButton } from '@/components/ui/CopyImageButton';
import { EditInAnnotatorButton } from '@/components/ui/EditInAnnotatorButton';
import CameraCapture from './CameraCapture';

export default function CameraTool() {
Expand All @@ -26,12 +24,9 @@ export default function CameraTool() {

{photo && (
<div className="space-y-2">
{/* ImageResult already renders Download / Copy image / Edit in Annotator. */}
<ImageResult blob={photo} filename={photo.name} />
<div className="flex flex-wrap gap-2">
<CopyImageButton blob={photo} />
<EditInAnnotatorButton blob={photo} filename={photo.name} />
<Button variant="secondary" onClick={retake}>Retake</Button>
</div>
<Button variant="secondary" onClick={retake}>Retake</Button>
</div>
)}
</div>
Expand Down
Loading