"use client"; /** * 摄像头预览:纯前端 getUserMedia 视频流,供调试抽屉的「视频流」视图使用。 * * 与 use-voice-preview 分离:这里负责摄像头设备枚举与本地预览;开始视觉会话时, * 调用方会把这条 video track 加进语音 WebRTC PeerConnection。 */ import { useCallback, useEffect, useRef, useState } from "react"; const CAMERA_VIDEO_CONSTRAINTS: MediaTrackConstraints = { width: { ideal: 640, max: 640 }, height: { ideal: 360, max: 480 }, frameRate: { ideal: 12, max: 15 }, }; function cameraErrorMessage(error: unknown): string { if (error instanceof DOMException) { if (error.name === "NotAllowedError") return "摄像头权限被拒绝,请在浏览器网站设置中允许使用摄像头。"; if (error.name === "NotFoundError") return "未检测到可用摄像头,请连接或启用摄像头后重试。"; if (error.name === "NotReadableError") return "摄像头正被其他应用占用,或系统未允许浏览器访问摄像头。"; } if (error instanceof Error && error.message) return error.message; return "无法访问摄像头。"; } export function useCameraPreview() { const [stream, setStream] = useState(null); const [error, setError] = useState(null); const [starting, setStarting] = useState(false); const [devices, setDevices] = useState([]); const [deviceId, setDeviceId] = useState(""); const streamRef = useRef(null); const deviceIdRef = useRef(""); const active = Boolean(stream); const refreshCameras = useCallback(async () => { if (!navigator.mediaDevices?.enumerateDevices) return; try { const list = await navigator.mediaDevices.enumerateDevices(); setDevices(list.filter((d) => d.kind === "videoinput" && d.deviceId)); } catch { /* 忽略枚举失败 */ } }, []); const stop = useCallback(() => { streamRef.current?.getTracks().forEach((track) => track.stop()); streamRef.current = null; setStream(null); }, []); const start = useCallback(async (): Promise => { if (streamRef.current) return streamRef.current; setError(null); if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia) { setError("当前页面无法访问摄像头,请通过 https 或 localhost 访问。"); return null; } setStarting(true); try { const id = deviceIdRef.current; const videoConstraints: MediaTrackConstraints = { ...CAMERA_VIDEO_CONSTRAINTS, ...(id ? { deviceId: { exact: id } } : {}), }; const media = await navigator.mediaDevices.getUserMedia({ video: videoConstraints, }); streamRef.current = media; setStream(media); void refreshCameras(); return media; } catch (mediaError) { setError(cameraErrorMessage(mediaError)); return null; } finally { setStarting(false); } }, [refreshCameras]); const selectCamera = useCallback( async (id: string): Promise => { deviceIdRef.current = id; setDeviceId(id); if (streamRef.current) { stop(); return await start(); } return null; }, [start, stop], ); useEffect(() => { if (!navigator.mediaDevices?.enumerateDevices) return; // eslint-disable-next-line react-hooks/set-state-in-effect void refreshCameras(); navigator.mediaDevices.addEventListener("devicechange", refreshCameras); return () => { navigator.mediaDevices.removeEventListener("devicechange", refreshCameras); }; }, [refreshCameras]); useEffect(() => stop, [stop]); return { stream, error, starting, devices, deviceId, active, start, stop, selectCamera, }; } export type CameraPreview = ReturnType;