amanpay / web /src /media /useCamera.ts
MHamdan's picture
CI deploy ee7d435
36db998 verified
Raw
History Blame Contribute Delete
3.77 kB
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 }
}