File size: 3,772 Bytes
004f460 36db998 004f460 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 | import { useCallback, useEffect, useRef, useState } from 'react'
export type CameraStatus = 'idle' | 'requesting' | 'live' | 'denied' | 'nocamera' | 'inuse' | 'error'
/** Camera controller: requests the camera only on `start()`, prefers the front
* (user-facing) camera, supports device switching, captures a still JPEG, and —
* critically — STOPS ALL TRACKS on stop()/unmount so the camera light never
* stays on in the background. */
export function useCamera() {
const videoRef = useRef<HTMLVideoElement | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const [status, setStatus] = useState<CameraStatus>('idle')
const [devices, setDevices] = useState<MediaDeviceInfo[]>([])
const [activeDeviceId, setActiveDeviceId] = useState<string | null>(null)
const stop = useCallback(() => {
const s = streamRef.current
if (s) {
s.getTracks().forEach((t) => t.stop()) // release camera + mic tracks
streamRef.current = null
}
if (videoRef.current) videoRef.current.srcObject = null
setStatus('idle')
}, [])
const start = useCallback(async (deviceId?: string) => {
const md = navigator.mediaDevices
if (!md || !md.getUserMedia) { setStatus('nocamera'); return }
stop()
setStatus('requesting')
const constraints: MediaStreamConstraints = {
video: deviceId ? { deviceId: { exact: deviceId } } : { facingMode: 'user' },
audio: false,
}
try {
const stream = await md.getUserMedia(constraints)
streamRef.current = stream
if (videoRef.current) {
videoRef.current.srcObject = stream
try { await videoRef.current.play() } catch { /* autoplay guarded */ }
}
const id = stream.getVideoTracks()[0]?.getSettings?.().deviceId ?? deviceId ?? null
setActiveDeviceId(id)
setStatus('live')
try {
const list = await md.enumerateDevices()
setDevices(list.filter((d) => d.kind === 'videoinput'))
} catch { /* labels need permission; ignore */ }
} catch (e) {
const name = (e as Error)?.name
if (name === 'NotAllowedError' || name === 'SecurityError') setStatus('denied')
else if (name === 'NotFoundError' || name === 'OverconstrainedError') setStatus('nocamera')
else if (name === 'NotReadableError' || name === 'AbortError') setStatus('inuse')
else setStatus('error')
}
}, [stop])
// Attach the stream once the <video> is actually mounted. The preview element is
// rendered only when status === 'live', which happens AFTER start() runs, so the
// ref is null when start() first tries to attach — this effect does the real attach.
useEffect(() => {
const v = videoRef.current
if (status === 'live' && v && streamRef.current && v.srcObject !== streamRef.current) {
v.srcObject = streamRef.current
void v.play().catch(() => { /* autoplay guarded */ })
}
}, [status])
const switchTo = useCallback((deviceId: string) => start(deviceId), [start])
/** Draw the current frame to a canvas and return a JPEG data URI (or null). */
const capture = useCallback((maxW = 640, quality = 0.85): string | null => {
const v = videoRef.current
if (!v || !v.videoWidth) return null
const scale = Math.min(1, maxW / v.videoWidth)
const canvas = document.createElement('canvas')
canvas.width = Math.round(v.videoWidth * scale)
canvas.height = Math.round(v.videoHeight * scale)
const ctx = canvas.getContext('2d')
if (!ctx) return null
ctx.drawImage(v, 0, 0, canvas.width, canvas.height)
return canvas.toDataURL('image/jpeg', quality)
}, [])
useEffect(() => stop, [stop]) // stop tracks on unmount
return { videoRef, status, devices, activeDeviceId, start, stop, switchTo, capture }
}
|