All files / web/src/hooks useDeskViewCamera.ts

0% Statements 0/306
0% Branches 0/1
0% Functions 0/1
0% Lines 0/306

Press n or j to go to the next uncovered block, b, p or k for the previous block.

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 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     
'use client'

import { useCallback, useEffect, useRef, useState } from 'react'
import { DESK_VIEW_PATTERNS } from '@/types/vision'

export interface UseDeskViewCameraReturn {
  /** Whether camera is currently loading */
  isLoading: boolean
  /** Error message if camera failed */
  error: string | null
  /** Active video stream */
  videoStream: MediaStream | null
  /** Currently selected device */
  currentDevice: MediaDeviceInfo | null
  /** All available video input devices */
  availableDevices: MediaDeviceInfo[]
  /** Whether Desk View camera was auto-detected */
  isDeskViewDetected: boolean
  /** Current facing mode */
  facingMode: 'user' | 'environment'
  /** Whether torch is currently on */
  isTorchOn: boolean
  /** Whether torch is available on current device */
  isTorchAvailable: boolean

  /** Request camera access, optionally specifying device ID */
  requestCamera: (deviceId?: string) => Promise<void>
  /** Stop camera stream */
  stopCamera: () => void
  /** Refresh device list */
  enumerateDevices: () => Promise<MediaDeviceInfo[]>
  /** Flip between front and back camera */
  flipCamera: () => Promise<void>
  /** Toggle torch on/off */
  toggleTorch: () => Promise<void>
}

/**
 * Hook for managing camera access with Desk View auto-detection
 *
 * Prioritizes finding Apple's "Desk View" camera (via Continuity Camera),
 * but falls back to manual device selection if not available.
 */
export function useDeskViewCamera(): UseDeskViewCameraReturn {
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)
  const [videoStream, setVideoStream] = useState<MediaStream | null>(null)
  const [currentDevice, setCurrentDevice] = useState<MediaDeviceInfo | null>(null)
  const [availableDevices, setAvailableDevices] = useState<MediaDeviceInfo[]>([])
  const [isDeskViewDetected, setIsDeskViewDetected] = useState(false)
  const [facingMode, setFacingMode] = useState<'user' | 'environment'>('environment')
  const [isTorchOn, setIsTorchOn] = useState(false)
  const [isTorchAvailable, setIsTorchAvailable] = useState(false)

  const streamRef = useRef<MediaStream | null>(null)
  const requestIdRef = useRef(0) // Track request ID to ignore stale completions
  const facingModeRef = useRef<'user' | 'environment'>('environment')

  /**
   * Enumerate available video input devices
   */
  const enumerateDevices = useCallback(async (): Promise<MediaDeviceInfo[]> => {
    try {
      const devices = await navigator.mediaDevices.enumerateDevices()
      const videoInputs = devices.filter((d) => d.kind === 'videoinput')
      setAvailableDevices(videoInputs)
      return videoInputs
    } catch (err) {
      console.error('Failed to enumerate devices:', err)
      return []
    }
  }, [])

  /**
   * Check if a device label matches Desk View patterns
   */
  const isDeskViewDevice = useCallback((device: MediaDeviceInfo): boolean => {
    const label = device.label.toLowerCase()
    return DESK_VIEW_PATTERNS.some((pattern) => label.includes(pattern))
  }, [])

  /**
   * Find Desk View camera from device list
   */
  const findDeskViewCamera = useCallback(
    (devices: MediaDeviceInfo[]): MediaDeviceInfo | null => {
      for (const device of devices) {
        if (isDeskViewDevice(device)) {
          return device
        }
      }
      return null
    },
    [isDeskViewDevice]
  )

  /**
   * Check if torch is available on a video track
   */
  const checkTorchAvailability = useCallback((track: MediaStreamTrack): boolean => {
    try {
      const capabilities = track.getCapabilities() as MediaTrackCapabilities & {
        torch?: boolean
      }
      return capabilities.torch === true
    } catch {
      return false
    }
  }, [])

  /**
   * Apply torch setting to track
   */
  const applyTorch = useCallback(async (track: MediaStreamTrack, on: boolean): Promise<boolean> => {
    try {
      await track.applyConstraints({
        advanced: [{ torch: on } as MediaTrackConstraintSet],
      })
      return true
    } catch (err) {
      console.warn('[DeskViewCamera] Failed to apply torch:', err)
      return false
    }
  }, [])

  /**
   * Request camera access
   */
  const requestCamera = useCallback(
    async (deviceId?: string): Promise<void> => {
      const thisRequestId = ++requestIdRef.current
      setIsLoading(true)
      setError(null)

      try {
        // Stop any existing stream
        if (streamRef.current) {
          for (const track of streamRef.current.getTracks()) {
            track.stop()
          }
          streamRef.current = null
        }

        const devices = await enumerateDevices()

        // Check if this request is still the latest
        if (thisRequestId !== requestIdRef.current) return

        // If no deviceId specified, try to find Desk View
        let targetDeviceId = deviceId
        if (!targetDeviceId) {
          const deskViewDevice = findDeskViewCamera(devices)
          if (deskViewDevice) {
            targetDeviceId = deskViewDevice.deviceId
            setIsDeskViewDetected(true)
          } else {
            setIsDeskViewDetected(false)
          }
        }

        const constraints: MediaStreamConstraints = {
          video: {
            width: { ideal: 1920 },
            height: { ideal: 1440 },
            // Prefer widest angle lens (zoom: 1 = no zoom = widest)
            // @ts-expect-error - zoom is valid but not in TS types
            zoom: { ideal: 1 },
            // Try to disable face-tracking auto-focus (not all cameras support this)
            focusMode: 'continuous',
            ...(targetDeviceId
              ? { deviceId: { exact: targetDeviceId } }
              : { facingMode: { ideal: facingModeRef.current } }),
          },
          audio: false,
        }

        const stream = await navigator.mediaDevices.getUserMedia(constraints)

        // Check again if this request is still the latest
        if (thisRequestId !== requestIdRef.current) {
          for (const track of stream.getTracks()) {
            track.stop()
          }
          return
        }

        streamRef.current = stream
        setVideoStream(stream)

        // Find which device we got and check torch availability
        const videoTrack = stream.getVideoTracks()[0]
        if (videoTrack) {
          const settings = videoTrack.getSettings()
          const matchingDevice = devices.find((d) => d.deviceId === settings.deviceId)
          if (matchingDevice) {
            setCurrentDevice(matchingDevice)
            setIsDeskViewDetected(isDeskViewDevice(matchingDevice))
          }

          // Check torch availability
          const torchAvailable = checkTorchAvailability(videoTrack)
          setIsTorchAvailable(torchAvailable)
          setIsTorchOn(false)
        }

        setIsLoading(false)
      } catch (err) {
        console.error('[DeskViewCamera] Failed to access camera:', err)
        setError(err instanceof Error ? err.message : 'Failed to access camera')
        setIsLoading(false)
      }
    },
    [enumerateDevices, findDeskViewCamera, isDeskViewDevice, checkTorchAvailability]
  )

  /**
   * Stop the camera stream
   */
  const stopCamera = useCallback(() => {
    requestIdRef.current++

    if (streamRef.current) {
      for (const track of streamRef.current.getTracks()) {
        track.stop()
      }
      streamRef.current = null
    }
    setVideoStream(null)
    setCurrentDevice(null)
    setError(null)
    setIsTorchOn(false)
    setIsTorchAvailable(false)
  }, [])

  // Cleanup on unmount
  useEffect(() => {
    return () => {
      if (streamRef.current) {
        for (const track of streamRef.current.getTracks()) {
          track.stop()
        }
      }
    }
  }, [])

  // Listen for device changes (e.g., iPhone connected/disconnected)
  useEffect(() => {
    // Guard against SSR or unsupported environments
    if (typeof navigator === 'undefined' || !navigator.mediaDevices) {
      return
    }

    const handleDeviceChange = () => {
      enumerateDevices()
    }

    navigator.mediaDevices.addEventListener('devicechange', handleDeviceChange)
    return () => {
      navigator.mediaDevices.removeEventListener('devicechange', handleDeviceChange)
    }
  }, [enumerateDevices])

  /**
   * Flip between front and back camera
   */
  const flipCamera = useCallback(async () => {
    const newFacingMode = facingMode === 'user' ? 'environment' : 'user'
    facingModeRef.current = newFacingMode
    setFacingMode(newFacingMode)
    // Re-request camera with new facing mode (don't pass device ID to use facingMode)
    await requestCamera()
  }, [facingMode, requestCamera])

  /**
   * Toggle torch on/off
   */
  const toggleTorch = useCallback(async () => {
    if (!streamRef.current || !isTorchAvailable) return

    const videoTrack = streamRef.current.getVideoTracks()[0]
    if (!videoTrack) return

    const newState = !isTorchOn
    const success = await applyTorch(videoTrack, newState)
    if (success) {
      setIsTorchOn(newState)
    }
  }, [isTorchAvailable, isTorchOn, applyTorch])

  return {
    isLoading,
    error,
    videoStream,
    currentDevice,
    availableDevices,
    isDeskViewDetected,
    facingMode,
    isTorchOn,
    isTorchAvailable,
    requestCamera,
    stopCamera,
    enumerateDevices,
    flipCamera,
    toggleTorch,
  }
}