fix live analysis multi-device lock
这个提交包含在:
@@ -549,6 +549,7 @@ export default function LiveCamera() {
|
||||
const broadcastSessionIdRef = useRef<string | null>(null);
|
||||
const viewerPeerRef = useRef<RTCPeerConnection | null>(null);
|
||||
const viewerSessionIdRef = useRef<string | null>(null);
|
||||
const viewerRetryTimerRef = useRef<number>(0);
|
||||
const runtimeIdRef = useRef<number | null>(null);
|
||||
const heartbeatTimerRef = useRef<number>(0);
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
@@ -883,6 +884,10 @@ export default function LiveCamera() {
|
||||
}, []);
|
||||
|
||||
const closeViewerPeer = useCallback(() => {
|
||||
if (viewerRetryTimerRef.current) {
|
||||
window.clearTimeout(viewerRetryTimerRef.current);
|
||||
viewerRetryTimerRef.current = 0;
|
||||
}
|
||||
viewerSessionIdRef.current = null;
|
||||
if (viewerPeerRef.current) {
|
||||
viewerPeerRef.current.ontrack = null;
|
||||
@@ -1026,15 +1031,22 @@ export default function LiveCamera() {
|
||||
await peer.setLocalDescription(offer);
|
||||
await waitForIceGathering(peer);
|
||||
|
||||
const answer = await signalMediaViewerSession(mediaSessionId, {
|
||||
sdp: peer.localDescription?.sdp || "",
|
||||
type: peer.localDescription?.type || "offer",
|
||||
});
|
||||
try {
|
||||
const answer = await signalMediaViewerSession(mediaSessionId, {
|
||||
sdp: peer.localDescription?.sdp || "",
|
||||
type: peer.localDescription?.type || "offer",
|
||||
});
|
||||
|
||||
await peer.setRemoteDescription({
|
||||
type: answer.type as RTCSdpType,
|
||||
sdp: answer.sdp,
|
||||
});
|
||||
await peer.setRemoteDescription({
|
||||
type: answer.type as RTCSdpType,
|
||||
sdp: answer.sdp,
|
||||
});
|
||||
} catch (error) {
|
||||
if (viewerPeerRef.current === peer) {
|
||||
closeViewerPeer();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}, [closeViewerPeer]);
|
||||
|
||||
const stopCamera = useCallback(() => {
|
||||
@@ -1087,11 +1099,27 @@ export default function LiveCamera() {
|
||||
|
||||
void startViewerStream(runtimeSession.mediaSessionId).catch((error: any) => {
|
||||
const message = error?.message || "同步画面连接失败";
|
||||
if (!/409/.test(message)) {
|
||||
setViewerError(message);
|
||||
if (/409|viewer stream not ready/i.test(message)) {
|
||||
setViewerError("持有端正在准备同步画面,正在自动重试...");
|
||||
if (!viewerRetryTimerRef.current) {
|
||||
viewerRetryTimerRef.current = window.setTimeout(() => {
|
||||
viewerRetryTimerRef.current = 0;
|
||||
void runtimeQuery.refetch();
|
||||
}, 1200);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setViewerError(message);
|
||||
});
|
||||
}, [cameraActive, closeViewerPeer, runtimeRole, runtimeSession?.mediaSessionId, startViewerStream]);
|
||||
}, [
|
||||
cameraActive,
|
||||
closeViewerPeer,
|
||||
runtimeQuery.refetch,
|
||||
runtimeQuery.dataUpdatedAt,
|
||||
runtimeRole,
|
||||
runtimeSession?.mediaSessionId,
|
||||
startViewerStream,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -189,6 +189,10 @@ function summarizeActions(actionSummary: Record<ActionType, number>) {
|
||||
export default function Recorder() {
|
||||
const { user } = useAuth();
|
||||
const utils = trpc.useUtils();
|
||||
const runtimeQuery = trpc.analysis.runtimeGet.useQuery(undefined, {
|
||||
refetchInterval: 1000,
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
const finalizeTaskMutation = trpc.task.createMediaFinalize.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setArchiveTaskId(data.taskId);
|
||||
@@ -262,6 +266,9 @@ export default function Recorder() {
|
||||
|
||||
const mobile = useMemo(() => isMobileDevice(), []);
|
||||
const mimeType = useMemo(() => pickRecorderMimeType(), []);
|
||||
const runtimeRole = runtimeQuery.data?.role ?? "idle";
|
||||
const liveAnalysisRuntime = runtimeQuery.data?.runtimeSession;
|
||||
const liveAnalysisOccupied = runtimeRole === "viewer" && liveAnalysisRuntime?.status === "active";
|
||||
const currentPlaybackUrl = mediaSession?.playback.mp4Url || mediaSession?.playback.webmUrl || "";
|
||||
const archiveTaskQuery = useBackgroundTask(archiveTaskId);
|
||||
const archiveProgress = archiveTaskQuery.data?.progress ?? getArchiveProgress(mediaSession);
|
||||
@@ -402,6 +409,11 @@ export default function Recorder() {
|
||||
preferredZoom = zoomTargetRef.current,
|
||||
preset: keyof typeof QUALITY_PRESETS = qualityPreset,
|
||||
) => {
|
||||
if (liveAnalysisOccupied) {
|
||||
const title = liveAnalysisRuntime?.title || "其他设备正在实时分析";
|
||||
toast.error(`${title},当前设备不能再开启录制摄像头`);
|
||||
throw new Error("当前账号已有其他设备正在实时分析");
|
||||
}
|
||||
try {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
@@ -440,7 +452,7 @@ export default function Recorder() {
|
||||
toast.error(`摄像头启动失败: ${message}`);
|
||||
throw error;
|
||||
}
|
||||
}), [facingMode, mobile, qualityPreset, syncZoomState]);
|
||||
}), [facingMode, liveAnalysisOccupied, liveAnalysisRuntime?.title, mobile, qualityPreset, syncZoomState]);
|
||||
|
||||
const ensurePreviewStream = useCallback(async () => {
|
||||
if (streamRef.current) {
|
||||
@@ -849,6 +861,11 @@ export default function Recorder() {
|
||||
toast.error("请先登录后再开始录制");
|
||||
return;
|
||||
}
|
||||
if (liveAnalysisOccupied) {
|
||||
const title = liveAnalysisRuntime?.title || "其他设备正在实时分析";
|
||||
toast.error(`${title},当前设备不能同时开始录制`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setMode("preparing");
|
||||
@@ -898,7 +915,21 @@ export default function Recorder() {
|
||||
setMode("idle");
|
||||
toast.error(`启动录制失败: ${error?.message || "未知错误"}`);
|
||||
}
|
||||
}, [ensurePreviewStream, facingMode, mimeType, mobile, qualityPreset, startActionSampling, startRealtimePush, startRecorderLoop, syncSessionState, title, user]);
|
||||
}, [
|
||||
ensurePreviewStream,
|
||||
facingMode,
|
||||
liveAnalysisOccupied,
|
||||
liveAnalysisRuntime?.title,
|
||||
mimeType,
|
||||
mobile,
|
||||
qualityPreset,
|
||||
startActionSampling,
|
||||
startRealtimePush,
|
||||
startRecorderLoop,
|
||||
syncSessionState,
|
||||
title,
|
||||
user,
|
||||
]);
|
||||
|
||||
const finishRecording = useCallback(async () => {
|
||||
const session = currentSessionRef.current;
|
||||
@@ -1140,9 +1171,10 @@ export default function Recorder() {
|
||||
data-testid="recorder-start-camera-button"
|
||||
onClick={() => void startCamera()}
|
||||
className={buttonClass()}
|
||||
disabled={liveAnalysisOccupied}
|
||||
>
|
||||
<Camera className={iconClass} />
|
||||
{labelFor("启动摄像头", "启动")}
|
||||
{labelFor(liveAnalysisOccupied ? "实时分析占用中" : "启动摄像头", liveAnalysisOccupied ? "占用" : "启动")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
@@ -1150,9 +1182,10 @@ export default function Recorder() {
|
||||
data-testid="recorder-start-recording-button"
|
||||
onClick={() => void beginRecording()}
|
||||
className={buttonClass("record")}
|
||||
disabled={liveAnalysisOccupied}
|
||||
>
|
||||
<Circle className={`${iconClass} ${rail ? "fill-current" : "fill-current"}`} />
|
||||
{labelFor("开始录制", "录制")}
|
||||
{labelFor(liveAnalysisOccupied ? "实时分析占用中" : "开始录制", liveAnalysisOccupied ? "占用" : "录制")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={stopCamera} className={buttonClass("outline")}>
|
||||
<VideoOff className={iconClass} />
|
||||
@@ -1362,6 +1395,23 @@ export default function Recorder() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{liveAnalysisOccupied ? (
|
||||
<Alert className="border-amber-300/70 bg-amber-50 text-amber-950">
|
||||
<ShieldAlert className="h-4 w-4" />
|
||||
<AlertTitle>当前账号已有其他设备正在实时分析</AlertTitle>
|
||||
<AlertDescription>
|
||||
{liveAnalysisRuntime?.title || "其他设备正在实时分析"},本页已禁止再次启动摄像头和录制,避免同账号多端同时占用镜头。
|
||||
你可以前往
|
||||
{" "}
|
||||
<a href="/live-camera" className="font-medium underline underline-offset-4">
|
||||
实时分析页
|
||||
</a>
|
||||
{" "}
|
||||
查看同步画面与动作识别结果。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.7fr)_minmax(340px,0.9fr)]">
|
||||
<section className="space-y-4">
|
||||
<Card className="overflow-hidden border-0 shadow-lg">
|
||||
|
||||
在新工单中引用
屏蔽一个用户