Files
ai-video-fullstack/frontend/src/hooks/use-camera-preview.ts
T

116 lines
3.6 KiB
TypeScript
Raw Normal View History

"use client";
/**
* 摄像头预览:纯前端 getUserMedia 视频流,供调试抽屉的「视频流」视图使用。
*
* 与 use-voice-preview 分离:这里负责摄像头设备枚举与本地预览;开始视觉会话时,
* 调用方会把这条 video track 加进语音 WebRTC PeerConnection。
*/
import { useCallback, useEffect, useRef, useState } from "react";
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<MediaStream | null>(null);
const [error, setError] = useState<string | null>(null);
const [starting, setStarting] = useState(false);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [deviceId, setDeviceId] = useState("");
const streamRef = useRef<MediaStream | null>(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<MediaStream | null> => {
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 media = await navigator.mediaDevices.getUserMedia({
video: id ? { deviceId: { exact: id } } : true,
});
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<MediaStream | null> => {
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<typeof useCameraPreview>;