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 }
}