File size: 3,812 Bytes
df3ef41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
90
91
92
93
94
95
96
import { useEffect, useRef, useState } from "react";

/**
 * Attach a live browser camera stream to a `<video>` element by deviceId.
 * Set `paused=true` to release the stream (e.g. so cv2.VideoCapture can claim
 * the camera exclusively). The stream is auto-stopped on unmount.
 *
 * A first getUserMedia attempt can fail two ways that we want to recover from:
 *   - Transiently: the device is briefly held (e.g. right after an enumeration
 *     probe releases it) and reports NotReadableError/AbortError. We back off
 *     and retry the same deviceId a few times.
 *   - Pending an external change: camera permission hasn't been granted yet, or
 *     the device isn't connected. Retrying the same call won't help, but the
 *     browser can fire `devicechange` following a permission grant or a
 *     device-exposure change (timing isn't guaranteed) — so we re-attempt on
 *     that event instead. Without this the hook would otherwise stay stuck on
 *     the error state until a full page reload.
 */
const MAX_RETRIES = 4;
const BASE_DELAY_MS = 300;
// Errors worth an immediate retry. Permission denial (NotAllowedError), missing
// device (NotFoundError) and unsatisfiable constraints (OverconstrainedError)
// won't fix themselves on a retimed retry — those recover via `devicechange`.
const TRANSIENT_ERRORS = new Set(["NotReadableError", "AbortError"]);

export function useCameraStream(deviceId: string, paused: boolean) {
  const videoRef = useRef<HTMLVideoElement>(null);
  const [hasError, setHasError] = useState(false);
  // Bumping this forces the stream effect to re-run (a clean retry).
  const [retryKey, setRetryKey] = useState(0);
  // Track the error state for the devicechange handler without re-binding it.
  const hasErrorRef = useRef(false);
  hasErrorRef.current = hasError;

  // A permission grant or hot-plug can prompt the browser to emit
  // `devicechange`; use it to re-attempt a stream that failed pre-permission.
  // Only retry when we're actually in the error state, so an unrelated change
  // (e.g. plugging in a mic) never tears down a healthy stream.
  useEffect(() => {
    const onDeviceChange = () => {
      if (hasErrorRef.current) setRetryKey((k) => k + 1);
    };
    navigator.mediaDevices.addEventListener("devicechange", onDeviceChange);
    return () =>
      navigator.mediaDevices.removeEventListener("devicechange", onDeviceChange);
  }, []);

  useEffect(() => {
    if (paused || !deviceId) {
      if (!deviceId) setHasError(true);
      return;
    }
    let cancelled = false;
    let stream: MediaStream | null = null;
    let retryTimer: ReturnType<typeof setTimeout> | null = null;
    setHasError(false);

    const start = async (attempt: number) => {
      try {
        stream = await navigator.mediaDevices.getUserMedia({
          video: { deviceId: { exact: deviceId } },
        });
        if (cancelled) {
          stream.getTracks().forEach((t) => t.stop());
          return;
        }
        if (videoRef.current) {
          videoRef.current.srcObject = stream;
          await videoRef.current.play().catch(() => {});
        }
      } catch (err) {
        if (cancelled) return;
        const name = err instanceof DOMException ? err.name : "";
        if (attempt < MAX_RETRIES && TRANSIENT_ERRORS.has(name)) {
          // Exponential backoff: 300ms, 600ms, 1200ms, ...
          retryTimer = setTimeout(
            () => start(attempt + 1),
            BASE_DELAY_MS * 2 ** attempt
          );
        } else {
          setHasError(true);
        }
      }
    };
    start(0);

    return () => {
      cancelled = true;
      if (retryTimer) clearTimeout(retryTimer);
      if (stream) stream.getTracks().forEach((t) => t.stop());
    };
  }, [deviceId, paused, retryKey]);

  return { videoRef, hasError };
}