@uniai-fe/uds-templates 0.6.8 → 0.6.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cctv/hooks/streamRegistry.ts +94 -1
- package/src/cctv/hooks/useRtcStream.ts +249 -21
- package/src/cctv/types/hook.ts +4 -2
- package/src/cctv/types/props.ts +5 -4
- package/src/cctv/types/video-state.ts +4 -4
- package/src/cctv/utils/debug.ts +179 -0
- package/src/cctv/utils/video-state.ts +1 -4
package/package.json
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { startWhepStream, type WhepStreamHandle } from "@uniai-fe/util-rtc";
|
|
4
|
+
import {
|
|
5
|
+
getCctvDebugKeyLabel,
|
|
6
|
+
getCctvDebugUrlLabel,
|
|
7
|
+
logCctvDebugEvent,
|
|
8
|
+
} from "../utils/debug";
|
|
4
9
|
|
|
5
10
|
interface CctvRtcStreamSnapshot {
|
|
6
11
|
connectionState: RTCPeerConnectionState;
|
|
@@ -47,6 +52,18 @@ const notifyEntry = (entry: CctvRtcStreamEntry) => {
|
|
|
47
52
|
};
|
|
48
53
|
|
|
49
54
|
const closeEntry = (entry: CctvRtcStreamEntry) => {
|
|
55
|
+
logCctvDebugEvent({
|
|
56
|
+
event: "entry:close",
|
|
57
|
+
payload: {
|
|
58
|
+
attachedVideoCount: entry.attachedVideos.size,
|
|
59
|
+
connectionState: entry.connectionState,
|
|
60
|
+
identityKey: getCctvDebugKeyLabel(entry.identityKey),
|
|
61
|
+
isStreaming: entry.isStreaming,
|
|
62
|
+
streamKey: getCctvDebugKeyLabel(entry.streamKey),
|
|
63
|
+
streamTrackCount: entry.stream?.getTracks().length ?? 0,
|
|
64
|
+
},
|
|
65
|
+
source: "streamRegistry",
|
|
66
|
+
});
|
|
50
67
|
entry.controller?.abort();
|
|
51
68
|
entry.controller = null;
|
|
52
69
|
entry.handle?.close();
|
|
@@ -110,6 +127,15 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
|
|
|
110
127
|
entries.clear();
|
|
111
128
|
},
|
|
112
129
|
closeByIdentity(identityKey, exceptStreamKey) {
|
|
130
|
+
logCctvDebugEvent({
|
|
131
|
+
event: "identity:close",
|
|
132
|
+
payload: {
|
|
133
|
+
entryCount: entries.size,
|
|
134
|
+
exceptStreamKey: getCctvDebugKeyLabel(exceptStreamKey),
|
|
135
|
+
identityKey: getCctvDebugKeyLabel(identityKey),
|
|
136
|
+
},
|
|
137
|
+
source: "streamRegistry",
|
|
138
|
+
});
|
|
113
139
|
entries.forEach(entry => {
|
|
114
140
|
if (
|
|
115
141
|
entry.identityKey === identityKey &&
|
|
@@ -124,9 +150,22 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
|
|
|
124
150
|
},
|
|
125
151
|
start({ endpoint, identityKey, streamKey, token, video }) {
|
|
126
152
|
const existingEntry = entries.get(streamKey);
|
|
127
|
-
if (existingEntry)
|
|
153
|
+
if (existingEntry) {
|
|
154
|
+
logCctvDebugEvent({
|
|
155
|
+
event: "start:reuse-existing",
|
|
156
|
+
payload: {
|
|
157
|
+
connectionState: existingEntry.connectionState,
|
|
158
|
+
identityKey: getCctvDebugKeyLabel(identityKey),
|
|
159
|
+
isStreaming: existingEntry.isStreaming,
|
|
160
|
+
streamKey: getCctvDebugKeyLabel(streamKey),
|
|
161
|
+
},
|
|
162
|
+
source: "streamRegistry",
|
|
163
|
+
});
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
128
166
|
|
|
129
167
|
const controller = new AbortController();
|
|
168
|
+
const startedAt = Date.now();
|
|
130
169
|
const entry: CctvRtcStreamEntry = {
|
|
131
170
|
attachedVideos: new Set([video]),
|
|
132
171
|
connectionState: "connecting",
|
|
@@ -142,6 +181,16 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
|
|
|
142
181
|
|
|
143
182
|
entries.set(streamKey, entry);
|
|
144
183
|
attachVideo(video, null);
|
|
184
|
+
logCctvDebugEvent({
|
|
185
|
+
event: "start:create-entry",
|
|
186
|
+
payload: {
|
|
187
|
+
attachedVideoCount: entry.attachedVideos.size,
|
|
188
|
+
endpoint: getCctvDebugUrlLabel(endpoint),
|
|
189
|
+
identityKey: getCctvDebugKeyLabel(identityKey),
|
|
190
|
+
streamKey: getCctvDebugKeyLabel(streamKey),
|
|
191
|
+
},
|
|
192
|
+
source: "streamRegistry",
|
|
193
|
+
});
|
|
145
194
|
|
|
146
195
|
startWhepStream({
|
|
147
196
|
endpoint,
|
|
@@ -150,10 +199,33 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
|
|
|
150
199
|
signal: controller.signal,
|
|
151
200
|
onConnectionStateChange: state => {
|
|
152
201
|
entry.connectionState = state;
|
|
202
|
+
logCctvDebugEvent({
|
|
203
|
+
event: "connection-state:change",
|
|
204
|
+
payload: {
|
|
205
|
+
elapsedMs: Date.now() - startedAt,
|
|
206
|
+
identityKey: getCctvDebugKeyLabel(identityKey),
|
|
207
|
+
state,
|
|
208
|
+
streamKey: getCctvDebugKeyLabel(streamKey),
|
|
209
|
+
},
|
|
210
|
+
source: "streamRegistry",
|
|
211
|
+
});
|
|
153
212
|
notifyEntry(entry);
|
|
154
213
|
},
|
|
155
214
|
onTrack: event => {
|
|
156
215
|
entry.stream = event.streams[0] ?? null;
|
|
216
|
+
logCctvDebugEvent({
|
|
217
|
+
event: "track:received",
|
|
218
|
+
payload: {
|
|
219
|
+
elapsedMs: Date.now() - startedAt,
|
|
220
|
+
identityKey: getCctvDebugKeyLabel(identityKey),
|
|
221
|
+
streamId: entry.stream?.id ?? null,
|
|
222
|
+
streamKey: getCctvDebugKeyLabel(streamKey),
|
|
223
|
+
trackId: event.track.id,
|
|
224
|
+
trackKind: event.track.kind,
|
|
225
|
+
trackReadyState: event.track.readyState,
|
|
226
|
+
},
|
|
227
|
+
source: "streamRegistry",
|
|
228
|
+
});
|
|
157
229
|
entry.attachedVideos.forEach(attachedVideo =>
|
|
158
230
|
attachVideo(attachedVideo, entry.stream),
|
|
159
231
|
);
|
|
@@ -163,6 +235,16 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
|
|
|
163
235
|
.then(handle => {
|
|
164
236
|
entry.handle = handle;
|
|
165
237
|
entry.isStreaming = false;
|
|
238
|
+
logCctvDebugEvent({
|
|
239
|
+
event: "start:resolved",
|
|
240
|
+
level: "info",
|
|
241
|
+
payload: {
|
|
242
|
+
elapsedMs: Date.now() - startedAt,
|
|
243
|
+
identityKey: getCctvDebugKeyLabel(identityKey),
|
|
244
|
+
streamKey: getCctvDebugKeyLabel(streamKey),
|
|
245
|
+
},
|
|
246
|
+
source: "streamRegistry",
|
|
247
|
+
});
|
|
166
248
|
notifyEntry(entry);
|
|
167
249
|
})
|
|
168
250
|
.catch(error => {
|
|
@@ -174,6 +256,17 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
|
|
|
174
256
|
error instanceof Error
|
|
175
257
|
? error.message
|
|
176
258
|
: "스트림 연결에 실패했습니다.";
|
|
259
|
+
logCctvDebugEvent({
|
|
260
|
+
event: "start:rejected",
|
|
261
|
+
level: "error",
|
|
262
|
+
payload: {
|
|
263
|
+
elapsedMs: Date.now() - startedAt,
|
|
264
|
+
errorMessage: entry.streamError,
|
|
265
|
+
identityKey: getCctvDebugKeyLabel(identityKey),
|
|
266
|
+
streamKey: getCctvDebugKeyLabel(streamKey),
|
|
267
|
+
},
|
|
268
|
+
source: "streamRegistry",
|
|
269
|
+
});
|
|
177
270
|
entry.attachedVideos.forEach(attachedVideo =>
|
|
178
271
|
attachVideo(attachedVideo, null),
|
|
179
272
|
);
|
|
@@ -19,6 +19,11 @@ import type {
|
|
|
19
19
|
UseCctvRtcStreamReturn,
|
|
20
20
|
} from "../types";
|
|
21
21
|
import { cctvRtcLiveRegistryAtom } from "../jotai/rtc";
|
|
22
|
+
import {
|
|
23
|
+
getCctvDebugKeyLabel,
|
|
24
|
+
getCctvDebugStackTrace,
|
|
25
|
+
logCctvDebugEvent,
|
|
26
|
+
} from "../utils/debug";
|
|
22
27
|
import { getIsLive } from "../utils/video-state";
|
|
23
28
|
import { useFormContext, useWatch } from "react-hook-form";
|
|
24
29
|
|
|
@@ -27,16 +32,10 @@ const POST_CONNECTED_RECONNECT_GRACE_MS = 5000;
|
|
|
27
32
|
const POST_CONNECTED_RECONNECT_STAGGER_MS = 5000;
|
|
28
33
|
const TOKEN_EXPIRY_SAFETY_MS = 30 * 1000;
|
|
29
34
|
|
|
30
|
-
type CctvRtcReconnectReason =
|
|
31
|
-
| "tokenError"
|
|
32
|
-
| "streamError"
|
|
33
|
-
| "failed"
|
|
34
|
-
| "disconnected";
|
|
35
|
+
type CctvRtcReconnectReason = "tokenError" | "streamError" | "failed";
|
|
35
36
|
|
|
36
|
-
const
|
|
37
|
-
"disconnected",
|
|
38
|
-
"failed",
|
|
39
|
-
]);
|
|
37
|
+
const DISPLAY_CONNECTED_DURING_RECOVERY_STATES =
|
|
38
|
+
new Set<RTCPeerConnectionState>(["disconnected", "failed"]);
|
|
40
39
|
|
|
41
40
|
/**
|
|
42
41
|
* 같은 회사/카메라 묶음이 동시에 재연결하지 않도록 identity 기반 지연값을 만든다.
|
|
@@ -65,6 +64,8 @@ const getPostConnectedReconnectDelayMs = (delayKey: string): number =>
|
|
|
65
64
|
* @desc
|
|
66
65
|
* `closed`는 `streamRegistry.closeByIdentity()`처럼 UDS가 의도적으로 닫을 때도 발생하므로
|
|
67
66
|
* 자동 재연결 사유에 포함하지 않는다.
|
|
67
|
+
* `disconnected`는 ICE transient 상태에서도 발생하고 기존 MediaStream track이 살아 있을 수 있으므로,
|
|
68
|
+
* media-health 확인 없이 자동 재연결을 유발하지 않는다.
|
|
68
69
|
*/
|
|
69
70
|
const getPostConnectedReconnectReason = ({
|
|
70
71
|
connectionState,
|
|
@@ -78,7 +79,6 @@ const getPostConnectedReconnectReason = ({
|
|
|
78
79
|
if (isTokenError) return "tokenError";
|
|
79
80
|
if (streamError) return "streamError";
|
|
80
81
|
if (connectionState === "failed") return "failed";
|
|
81
|
-
if (connectionState === "disconnected") return "disconnected";
|
|
82
82
|
return null;
|
|
83
83
|
};
|
|
84
84
|
|
|
@@ -236,9 +236,23 @@ export function useCctvRtcStream({
|
|
|
236
236
|
return [tokenUsername, cam.company_id, cam.cam_id, endpoint].join("|");
|
|
237
237
|
}, [cam?.cam_id, cam?.company_id, endpoint, tokenUsername]);
|
|
238
238
|
|
|
239
|
+
const debugBasePayload = useMemo(
|
|
240
|
+
() => ({
|
|
241
|
+
camId: cam?.cam_id,
|
|
242
|
+
companyId: cam?.company_id,
|
|
243
|
+
streamIdentityKey: getCctvDebugKeyLabel(streamIdentityKey),
|
|
244
|
+
}),
|
|
245
|
+
[cam?.cam_id, cam?.company_id, streamIdentityKey],
|
|
246
|
+
);
|
|
247
|
+
|
|
239
248
|
useEffect(() => {
|
|
249
|
+
logCctvDebugEvent({
|
|
250
|
+
event: "identity:reset-has-connected",
|
|
251
|
+
payload: debugBasePayload,
|
|
252
|
+
source: "useRtcStream",
|
|
253
|
+
});
|
|
240
254
|
setHasConnected(false);
|
|
241
|
-
}, [streamIdentityKey]);
|
|
255
|
+
}, [debugBasePayload, streamIdentityKey]);
|
|
242
256
|
|
|
243
257
|
const reconnectReason = useMemo(
|
|
244
258
|
() =>
|
|
@@ -264,20 +278,49 @@ export function useCctvRtcStream({
|
|
|
264
278
|
}
|
|
265
279
|
|
|
266
280
|
setPostConnectedReconnectReady(false);
|
|
281
|
+
const reconnectDelayMs = getPostConnectedReconnectDelayMs(
|
|
282
|
+
`${streamIdentityKey}|${reconnectReason}`,
|
|
283
|
+
);
|
|
267
284
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
285
|
+
logCctvDebugEvent({
|
|
286
|
+
event: "reconnect-gate:timer-start",
|
|
287
|
+
payload: {
|
|
288
|
+
...debugBasePayload,
|
|
289
|
+
isStreaming,
|
|
290
|
+
isTokenLoading,
|
|
291
|
+
reconnectDelayMs,
|
|
292
|
+
reconnectReason,
|
|
271
293
|
},
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
)
|
|
294
|
+
source: "useRtcStream",
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const timeout = setTimeout(() => {
|
|
298
|
+
setPostConnectedReconnectReady(true);
|
|
299
|
+
logCctvDebugEvent({
|
|
300
|
+
event: "reconnect-gate:ready",
|
|
301
|
+
payload: {
|
|
302
|
+
...debugBasePayload,
|
|
303
|
+
reconnectDelayMs,
|
|
304
|
+
reconnectReason,
|
|
305
|
+
},
|
|
306
|
+
source: "useRtcStream",
|
|
307
|
+
});
|
|
308
|
+
}, reconnectDelayMs);
|
|
276
309
|
|
|
277
310
|
return () => {
|
|
278
311
|
clearTimeout(timeout);
|
|
312
|
+
logCctvDebugEvent({
|
|
313
|
+
event: "reconnect-gate:timer-cancel",
|
|
314
|
+
payload: {
|
|
315
|
+
...debugBasePayload,
|
|
316
|
+
reconnectDelayMs,
|
|
317
|
+
reconnectReason,
|
|
318
|
+
},
|
|
319
|
+
source: "useRtcStream",
|
|
320
|
+
});
|
|
279
321
|
};
|
|
280
322
|
}, [
|
|
323
|
+
debugBasePayload,
|
|
281
324
|
hasConnected,
|
|
282
325
|
isStreaming,
|
|
283
326
|
isTokenLoading,
|
|
@@ -288,12 +331,44 @@ export function useCctvRtcStream({
|
|
|
288
331
|
const shouldPreserveConnectedDisplay =
|
|
289
332
|
hasConnected &&
|
|
290
333
|
!isPostConnectedReconnectReady &&
|
|
291
|
-
|
|
334
|
+
DISPLAY_CONNECTED_DURING_RECOVERY_STATES.has(connectionState);
|
|
292
335
|
|
|
336
|
+
// 반환 state는 CamList/Viewer/overlay의 live/error/message 계산에 직접 쓰인다.
|
|
337
|
+
// post-connected disconnected는 자동 재연결 대신 기존 화면을 유지해 UI reset 파동을 막는다.
|
|
293
338
|
const displayConnectionState = shouldPreserveConnectedDisplay
|
|
294
339
|
? "connected"
|
|
295
340
|
: connectionState;
|
|
296
341
|
|
|
342
|
+
const skippedDisconnectedDebugKeyRef = useRef<string | null>(null);
|
|
343
|
+
|
|
344
|
+
useEffect(() => {
|
|
345
|
+
if (
|
|
346
|
+
connectionState !== "disconnected" ||
|
|
347
|
+
!hasConnected ||
|
|
348
|
+
!streamIdentityKey
|
|
349
|
+
) {
|
|
350
|
+
if (connectionState !== "disconnected") {
|
|
351
|
+
skippedDisconnectedDebugKeyRef.current = null;
|
|
352
|
+
}
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const skipKey = `${streamIdentityKey}|${activeStreamKeyRef.current ?? ""}`;
|
|
357
|
+
if (skippedDisconnectedDebugKeyRef.current === skipKey) return;
|
|
358
|
+
|
|
359
|
+
skippedDisconnectedDebugKeyRef.current = skipKey;
|
|
360
|
+
logCctvDebugEvent({
|
|
361
|
+
event: "reconnect-gate:skip-disconnected",
|
|
362
|
+
payload: {
|
|
363
|
+
...debugBasePayload,
|
|
364
|
+
connectionState,
|
|
365
|
+
reason: "disconnected-observed-only",
|
|
366
|
+
streamKey: getCctvDebugKeyLabel(activeStreamKeyRef.current),
|
|
367
|
+
},
|
|
368
|
+
source: "useRtcStream",
|
|
369
|
+
});
|
|
370
|
+
}, [connectionState, debugBasePayload, hasConnected, streamIdentityKey]);
|
|
371
|
+
|
|
297
372
|
const hasReusableRegistryStream = useMemo(() => {
|
|
298
373
|
if (!streamKeyCandidate) return false;
|
|
299
374
|
|
|
@@ -343,12 +418,23 @@ export function useCctvRtcStream({
|
|
|
343
418
|
}
|
|
344
419
|
|
|
345
420
|
staleTokenRefreshKeyRef.current = streamKeyCandidate;
|
|
421
|
+
logCctvDebugEvent({
|
|
422
|
+
event: "token:stale-refetch",
|
|
423
|
+
payload: {
|
|
424
|
+
...debugBasePayload,
|
|
425
|
+
dataUpdatedAt: tokenQuery.dataUpdatedAt,
|
|
426
|
+
streamKey: getCctvDebugKeyLabel(streamKeyCandidate),
|
|
427
|
+
},
|
|
428
|
+
source: "useRtcStream",
|
|
429
|
+
});
|
|
346
430
|
void refetchRtcToken();
|
|
347
431
|
}, [
|
|
348
432
|
canUseTokenForStream,
|
|
433
|
+
debugBasePayload,
|
|
349
434
|
refetchRtcToken,
|
|
350
435
|
streamKeyCandidate,
|
|
351
436
|
tokenQuery.data?.token,
|
|
437
|
+
tokenQuery.dataUpdatedAt,
|
|
352
438
|
tokenQuery.isError,
|
|
353
439
|
tokenQuery.isFetching,
|
|
354
440
|
]);
|
|
@@ -359,6 +445,15 @@ export function useCctvRtcStream({
|
|
|
359
445
|
|
|
360
446
|
// 토큰/카메라 에러로 전환되면 직전 MediaStream이 화면에 남지 않도록 비운다.
|
|
361
447
|
if (tokenQuery.isError || !cam?.cam_online) {
|
|
448
|
+
logCctvDebugEvent({
|
|
449
|
+
event: "stream-effect:cleanup-token-or-offline",
|
|
450
|
+
payload: {
|
|
451
|
+
...debugBasePayload,
|
|
452
|
+
camOnline: cam?.cam_online,
|
|
453
|
+
isTokenError: tokenQuery.isError,
|
|
454
|
+
},
|
|
455
|
+
source: "useRtcStream",
|
|
456
|
+
});
|
|
362
457
|
if (streamIdentityKey) {
|
|
363
458
|
streamRegistry.closeByIdentity(streamIdentityKey);
|
|
364
459
|
}
|
|
@@ -377,12 +472,30 @@ export function useCctvRtcStream({
|
|
|
377
472
|
activeStreamIdentityKeyRef.current === streamIdentityKey &&
|
|
378
473
|
activeStreamKeyRef.current !== streamKey
|
|
379
474
|
) {
|
|
475
|
+
logCctvDebugEvent({
|
|
476
|
+
event: "stream-effect:close-previous-identity",
|
|
477
|
+
payload: {
|
|
478
|
+
...debugBasePayload,
|
|
479
|
+
activeStreamKey: getCctvDebugKeyLabel(activeStreamKeyRef.current),
|
|
480
|
+
nextStreamKey: getCctvDebugKeyLabel(streamKey),
|
|
481
|
+
},
|
|
482
|
+
source: "useRtcStream",
|
|
483
|
+
});
|
|
380
484
|
streamRegistry.closeByIdentity(streamIdentityKey, streamKey);
|
|
381
485
|
}
|
|
382
486
|
|
|
383
487
|
activeStreamKeyRef.current = streamKey;
|
|
384
488
|
activeStreamIdentityKeyRef.current = streamIdentityKey;
|
|
385
489
|
|
|
490
|
+
logCctvDebugEvent({
|
|
491
|
+
event: "stream-effect:start",
|
|
492
|
+
payload: {
|
|
493
|
+
...debugBasePayload,
|
|
494
|
+
streamKey: getCctvDebugKeyLabel(streamKey),
|
|
495
|
+
},
|
|
496
|
+
source: "useRtcStream",
|
|
497
|
+
});
|
|
498
|
+
|
|
386
499
|
streamRegistry.start({
|
|
387
500
|
streamKey,
|
|
388
501
|
identityKey: streamIdentityKey,
|
|
@@ -396,7 +509,17 @@ export function useCctvRtcStream({
|
|
|
396
509
|
setConnectionState(snapshot.connectionState);
|
|
397
510
|
setStreamError(snapshot.streamError);
|
|
398
511
|
setStreaming(snapshot.isStreaming);
|
|
399
|
-
if (snapshot.connectionState === "connected")
|
|
512
|
+
if (snapshot.connectionState === "connected") {
|
|
513
|
+
logCctvDebugEvent({
|
|
514
|
+
event: "stream-effect:has-connected",
|
|
515
|
+
payload: {
|
|
516
|
+
...debugBasePayload,
|
|
517
|
+
streamKey: getCctvDebugKeyLabel(streamKey),
|
|
518
|
+
},
|
|
519
|
+
source: "useRtcStream",
|
|
520
|
+
});
|
|
521
|
+
setHasConnected(true);
|
|
522
|
+
}
|
|
400
523
|
if (snapshot.stream && currentVideo.srcObject !== snapshot.stream) {
|
|
401
524
|
currentVideo.srcObject = snapshot.stream;
|
|
402
525
|
}
|
|
@@ -413,10 +536,19 @@ export function useCctvRtcStream({
|
|
|
413
536
|
|
|
414
537
|
// effect cleanup: current video attach만 해제하고 registry stream은 유지한다.
|
|
415
538
|
return () => {
|
|
539
|
+
logCctvDebugEvent({
|
|
540
|
+
event: "stream-effect:detach-video",
|
|
541
|
+
payload: {
|
|
542
|
+
...debugBasePayload,
|
|
543
|
+
streamKey: getCctvDebugKeyLabel(streamKey),
|
|
544
|
+
},
|
|
545
|
+
source: "useRtcStream",
|
|
546
|
+
});
|
|
416
547
|
unsubscribe();
|
|
417
548
|
detachVideo();
|
|
418
549
|
};
|
|
419
550
|
}, [
|
|
551
|
+
debugBasePayload,
|
|
420
552
|
endpoint,
|
|
421
553
|
streamIdentityKey,
|
|
422
554
|
streamKey,
|
|
@@ -426,10 +558,56 @@ export function useCctvRtcStream({
|
|
|
426
558
|
cam?.cam_online,
|
|
427
559
|
]);
|
|
428
560
|
|
|
561
|
+
const debugReconnectStateRef = useRef({
|
|
562
|
+
connectionState,
|
|
563
|
+
debugBasePayload,
|
|
564
|
+
isPostConnectedReconnectReady,
|
|
565
|
+
reconnectReason,
|
|
566
|
+
streamError,
|
|
567
|
+
});
|
|
568
|
+
debugReconnectStateRef.current = {
|
|
569
|
+
connectionState,
|
|
570
|
+
debugBasePayload,
|
|
571
|
+
isPostConnectedReconnectReady,
|
|
572
|
+
reconnectReason,
|
|
573
|
+
streamError,
|
|
574
|
+
};
|
|
575
|
+
|
|
429
576
|
const reconnectStream = useCallback<CctvRtcReconnectTrigger>(
|
|
430
577
|
async options => {
|
|
578
|
+
const debugState = debugReconnectStateRef.current;
|
|
579
|
+
logCctvDebugEvent({
|
|
580
|
+
event: "reconnect-stream:start",
|
|
581
|
+
payload: {
|
|
582
|
+
...debugState.debugBasePayload,
|
|
583
|
+
connectionState: debugState.connectionState,
|
|
584
|
+
callerStack: getCctvDebugStackTrace(),
|
|
585
|
+
isPostConnectedReconnectReady:
|
|
586
|
+
debugState.isPostConnectedReconnectReady,
|
|
587
|
+
reconnectReason: debugState.reconnectReason,
|
|
588
|
+
streamError: debugState.streamError,
|
|
589
|
+
},
|
|
590
|
+
source: "useRtcStream",
|
|
591
|
+
});
|
|
431
592
|
const result = await refetchRtcToken(options);
|
|
593
|
+
const resultDebugState = debugReconnectStateRef.current;
|
|
594
|
+
logCctvDebugEvent({
|
|
595
|
+
event: "reconnect-stream:token-result",
|
|
596
|
+
level: result.isSuccess ? "info" : "warn",
|
|
597
|
+
payload: {
|
|
598
|
+
...resultDebugState.debugBasePayload,
|
|
599
|
+
isError: result.isError,
|
|
600
|
+
isSuccess: result.isSuccess,
|
|
601
|
+
status: result.status,
|
|
602
|
+
},
|
|
603
|
+
source: "useRtcStream",
|
|
604
|
+
});
|
|
432
605
|
if (result.isSuccess && streamIdentityKey) {
|
|
606
|
+
logCctvDebugEvent({
|
|
607
|
+
event: "reconnect-stream:close-identity",
|
|
608
|
+
payload: resultDebugState.debugBasePayload,
|
|
609
|
+
source: "useRtcStream",
|
|
610
|
+
});
|
|
433
611
|
streamRegistry.closeByIdentity(streamIdentityKey);
|
|
434
612
|
}
|
|
435
613
|
return result;
|
|
@@ -469,6 +647,16 @@ export function useCctvRtcStream({
|
|
|
469
647
|
return;
|
|
470
648
|
|
|
471
649
|
lastAutoReconnectAtRef.current = now;
|
|
650
|
+
logCctvDebugEvent({
|
|
651
|
+
event: "focus:auto-reconnect",
|
|
652
|
+
payload: {
|
|
653
|
+
...debugBasePayload,
|
|
654
|
+
canReconnect,
|
|
655
|
+
connectionState,
|
|
656
|
+
reconnectReason,
|
|
657
|
+
},
|
|
658
|
+
source: "useRtcStream",
|
|
659
|
+
});
|
|
472
660
|
void reconnectStream();
|
|
473
661
|
};
|
|
474
662
|
|
|
@@ -479,7 +667,13 @@ export function useCctvRtcStream({
|
|
|
479
667
|
window.removeEventListener("focus", reconnectOnFocus);
|
|
480
668
|
document.removeEventListener("visibilitychange", reconnectOnFocus);
|
|
481
669
|
};
|
|
482
|
-
}, [
|
|
670
|
+
}, [
|
|
671
|
+
canReconnect,
|
|
672
|
+
connectionState,
|
|
673
|
+
debugBasePayload,
|
|
674
|
+
reconnectReason,
|
|
675
|
+
reconnectStream,
|
|
676
|
+
]);
|
|
483
677
|
|
|
484
678
|
const liveState = useMemo(
|
|
485
679
|
() =>
|
|
@@ -503,6 +697,40 @@ export function useCctvRtcStream({
|
|
|
503
697
|
],
|
|
504
698
|
);
|
|
505
699
|
|
|
700
|
+
useEffect(() => {
|
|
701
|
+
logCctvDebugEvent({
|
|
702
|
+
event: "hook-state:update",
|
|
703
|
+
payload: {
|
|
704
|
+
...debugBasePayload,
|
|
705
|
+
canReconnect,
|
|
706
|
+
connectionState,
|
|
707
|
+
displayConnectionState,
|
|
708
|
+
hasConnected,
|
|
709
|
+
isPostConnectedReconnectReady,
|
|
710
|
+
isStreaming,
|
|
711
|
+
isTokenError,
|
|
712
|
+
isTokenLoading,
|
|
713
|
+
liveState,
|
|
714
|
+
reconnectReason,
|
|
715
|
+
streamError,
|
|
716
|
+
},
|
|
717
|
+
source: "useRtcStream",
|
|
718
|
+
});
|
|
719
|
+
}, [
|
|
720
|
+
canReconnect,
|
|
721
|
+
connectionState,
|
|
722
|
+
debugBasePayload,
|
|
723
|
+
displayConnectionState,
|
|
724
|
+
hasConnected,
|
|
725
|
+
isPostConnectedReconnectReady,
|
|
726
|
+
isStreaming,
|
|
727
|
+
isTokenError,
|
|
728
|
+
isTokenLoading,
|
|
729
|
+
liveState,
|
|
730
|
+
reconnectReason,
|
|
731
|
+
streamError,
|
|
732
|
+
]);
|
|
733
|
+
|
|
506
734
|
useEffect(() => {
|
|
507
735
|
const camId = cam?.cam_id;
|
|
508
736
|
if (!camId) return;
|
package/src/cctv/types/hook.ts
CHANGED
|
@@ -88,7 +88,7 @@ export interface UseCctvRtcStreamState extends UseCctvRtcStreamError {
|
|
|
88
88
|
* @property {boolean} isStreaming startWhepStream 진행 여부
|
|
89
89
|
* @property {boolean} isTokenLoading 토큰 발급 요청 진행 여부
|
|
90
90
|
* @property {boolean} isTokenError 토큰 발급 실패 여부
|
|
91
|
-
* @property {boolean} canReconnect UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
91
|
+
* @property {boolean} canReconnect UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
92
92
|
* @property {() => Promise<void>} refetchToken 토큰 재발급 함수
|
|
93
93
|
* @property {() => Promise<QueryObserverResult<API_Res_CctvRtcToken>>} reconnectStream 재연결 trigger 함수
|
|
94
94
|
*/
|
|
@@ -98,10 +98,12 @@ export interface UseCctvRtcStreamReturn extends UseCctvRtcStreamState {
|
|
|
98
98
|
*/
|
|
99
99
|
videoRef: React.RefObject<HTMLVideoElement | null>;
|
|
100
100
|
/**
|
|
101
|
-
* UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
101
|
+
* UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
102
102
|
* @desc
|
|
103
103
|
* 스트림 장애 감지 즉시 true가 되는 플래그가 아니라,
|
|
104
104
|
* 서비스 overlay가 reconnectStream()을 호출해도 되는 시점에 true가 된다.
|
|
105
|
+
* post-connected `disconnected`는 자동 재연결 사유가 아니며,
|
|
106
|
+
* token/stream 오류 또는 `failed`처럼 명확한 복구 사유만 true 전환 후보가 된다.
|
|
105
107
|
*/
|
|
106
108
|
canReconnect: boolean;
|
|
107
109
|
/**
|
package/src/cctv/types/props.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { CctvRtcReconnectTrigger } from "./hook";
|
|
|
9
9
|
* @property {boolean | undefined} [isError] 영상 에러 상태 여부
|
|
10
10
|
* @property {boolean | undefined} [isLive] 영상 live 상태 여부
|
|
11
11
|
* @property {React.ReactNode} [overlayMessage] 기본 안내/에러 메시지 콘텐츠
|
|
12
|
-
* @property {boolean | undefined} [canReconnect] UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
12
|
+
* @property {boolean | undefined} [canReconnect] UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
13
13
|
* @property {CctvRtcReconnectTrigger | undefined} [reconnectStream] 재연결 trigger 함수
|
|
14
14
|
*/
|
|
15
15
|
export interface CctvVideoRenderOverlayContext {
|
|
@@ -38,10 +38,11 @@ export interface CctvVideoRenderOverlayContext {
|
|
|
38
38
|
*/
|
|
39
39
|
overlayMessage?: React.ReactNode;
|
|
40
40
|
/**
|
|
41
|
-
* UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
41
|
+
* UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
42
42
|
* @desc
|
|
43
43
|
* custom overlay에서 자동 재연결을 구현할 때 이 값이 true인 경우에만
|
|
44
44
|
* reconnectStream()을 호출해야 한다.
|
|
45
|
+
* post-connected `disconnected`는 이 값만으로 자동 재연결하지 않는다.
|
|
45
46
|
*/
|
|
46
47
|
canReconnect?: boolean;
|
|
47
48
|
/**
|
|
@@ -129,7 +130,7 @@ export interface CctvVideoOverlayFooterProps {
|
|
|
129
130
|
* @property {boolean} [isError] 에러 상태 여부
|
|
130
131
|
* @property {boolean} [isLive] 영상이 정상적으로 재생 중인지 여부
|
|
131
132
|
* @property {React.ReactNode} [overlayMessage] 에러/안내 메시지 콘텐츠
|
|
132
|
-
* @property {boolean} [canReconnect] UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
133
|
+
* @property {boolean} [canReconnect] UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
133
134
|
* @property {CctvRtcReconnectTrigger} [reconnectStream] 재연결 trigger 함수
|
|
134
135
|
*/
|
|
135
136
|
export interface CctvVideoStateProps {
|
|
@@ -146,7 +147,7 @@ export interface CctvVideoStateProps {
|
|
|
146
147
|
*/
|
|
147
148
|
overlayMessage?: React.ReactNode;
|
|
148
149
|
/**
|
|
149
|
-
* UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
150
|
+
* UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
150
151
|
*/
|
|
151
152
|
canReconnect?: boolean;
|
|
152
153
|
/**
|
|
@@ -11,7 +11,7 @@ import type { UseCctvRtcStreamError, UseCctvRtcStreamState } from "./hook";
|
|
|
11
11
|
* @property {boolean} isTokenLoading 토큰 발급 요청 진행 여부
|
|
12
12
|
* @property {boolean} isTokenError 토큰 발급 실패 여부
|
|
13
13
|
* @property {string | null} streamError 스트림 오류 메시지
|
|
14
|
-
* @property {boolean} [canReconnect] UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
14
|
+
* @property {boolean} [canReconnect] UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
15
15
|
*/
|
|
16
16
|
export interface CctvVideoOverlayMessageParams extends UseCctvRtcStreamState {
|
|
17
17
|
/**
|
|
@@ -27,7 +27,7 @@ export interface CctvVideoOverlayMessageParams extends UseCctvRtcStreamState {
|
|
|
27
27
|
*/
|
|
28
28
|
isFetching?: boolean;
|
|
29
29
|
/**
|
|
30
|
-
* UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
30
|
+
* UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
31
31
|
*/
|
|
32
32
|
canReconnect?: boolean;
|
|
33
33
|
}
|
|
@@ -53,7 +53,7 @@ export interface CctvVideoLiveParams extends UseCctvRtcStreamState {
|
|
|
53
53
|
* @property {CctvCompanyCameraData} [cam] 카메라 데이터
|
|
54
54
|
* @property {boolean} isTokenError 토큰 발급 실패 여부
|
|
55
55
|
* @property {string | null} streamError 스트림 오류 메시지
|
|
56
|
-
* @property {boolean} [canReconnect] UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
56
|
+
* @property {boolean} [canReconnect] UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
57
57
|
*/
|
|
58
58
|
export interface CctvVideoErrorParams extends UseCctvRtcStreamError {
|
|
59
59
|
/**
|
|
@@ -61,7 +61,7 @@ export interface CctvVideoErrorParams extends UseCctvRtcStreamError {
|
|
|
61
61
|
*/
|
|
62
62
|
cam?: CctvCompanyCameraData;
|
|
63
63
|
/**
|
|
64
|
-
* UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
64
|
+
* UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
65
65
|
*/
|
|
66
66
|
canReconnect?: boolean;
|
|
67
67
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
export type CctvDebugEventLevel = "debug" | "info" | "warn" | "error";
|
|
4
|
+
|
|
5
|
+
export interface CctvDebugEvent {
|
|
6
|
+
at: string;
|
|
7
|
+
event: string;
|
|
8
|
+
level: CctvDebugEventLevel;
|
|
9
|
+
payload?: Record<string, unknown>;
|
|
10
|
+
seq: number;
|
|
11
|
+
source: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CctvDebugBuffer {
|
|
15
|
+
clear: () => void;
|
|
16
|
+
dump: () => CctvDebugEvent[];
|
|
17
|
+
enabled: true;
|
|
18
|
+
events: CctvDebugEvent[];
|
|
19
|
+
limit: number;
|
|
20
|
+
sequence: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
declare global {
|
|
24
|
+
interface Window {
|
|
25
|
+
__UDS_CCTV_DEBUG__?: CctvDebugBuffer;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEBUG_QUERY_KEYS = ["udsCctvDebug", "cctvDebug"] as const;
|
|
30
|
+
const DEBUG_STORAGE_KEYS = ["uds:cctv:debug", "UDS_CCTV_DEBUG"] as const;
|
|
31
|
+
const DEBUG_ENABLED_VALUES = new Set(["1", "true", "yes", "on", "debug"]);
|
|
32
|
+
const DEBUG_BUFFER_LIMIT = 1000;
|
|
33
|
+
|
|
34
|
+
const isBrowser = (): boolean =>
|
|
35
|
+
typeof window !== "undefined" && typeof document !== "undefined";
|
|
36
|
+
|
|
37
|
+
const isDebugValueEnabled = (value: string | null): boolean =>
|
|
38
|
+
value === "" ||
|
|
39
|
+
(typeof value === "string" && DEBUG_ENABLED_VALUES.has(value));
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* identity/stream key를 원문 대신 추적 가능한 hash label로 바꾼다.
|
|
43
|
+
*/
|
|
44
|
+
export const getCctvDebugKeyLabel = (
|
|
45
|
+
value: string | null | undefined,
|
|
46
|
+
): string | null => {
|
|
47
|
+
if (!value) return null;
|
|
48
|
+
|
|
49
|
+
let hash = 0;
|
|
50
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
51
|
+
hash = (hash * 31 + value.charCodeAt(i)) >>> 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return `hash:${hash.toString(36)}|len:${value.length}`;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* endpoint는 query string을 제거한 origin/path만 기록한다.
|
|
59
|
+
*/
|
|
60
|
+
export const getCctvDebugUrlLabel = (
|
|
61
|
+
value: string | null | undefined,
|
|
62
|
+
): string | null => {
|
|
63
|
+
if (!value) return null;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const url = new URL(value);
|
|
67
|
+
return `${url.origin}${url.pathname}`;
|
|
68
|
+
} catch {
|
|
69
|
+
return value.split("?")[0] ?? null;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* CCTV debug logging 활성화 여부를 확인한다.
|
|
75
|
+
* @desc
|
|
76
|
+
* production default는 off다. `?udsCctvDebug=1` 또는
|
|
77
|
+
* `localStorage.setItem("uds:cctv:debug", "1")`로 runtime에서 켤 수 있다.
|
|
78
|
+
*/
|
|
79
|
+
export const isCctvDebugEnabled = (): boolean => {
|
|
80
|
+
if (!isBrowser()) return false;
|
|
81
|
+
|
|
82
|
+
const query = new URLSearchParams(window.location.search);
|
|
83
|
+
for (const key of DEBUG_QUERY_KEYS) {
|
|
84
|
+
if (query.has(key) && isDebugValueEnabled(query.get(key))) return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const key of DEBUG_STORAGE_KEYS) {
|
|
88
|
+
try {
|
|
89
|
+
if (isDebugValueEnabled(window.localStorage.getItem(key))) return true;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return false;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* reconnect 호출 주체를 좁히기 위한 stack trace 일부를 만든다.
|
|
100
|
+
*/
|
|
101
|
+
export const getCctvDebugStackTrace = (): string[] | null => {
|
|
102
|
+
if (!isCctvDebugEnabled()) return null;
|
|
103
|
+
|
|
104
|
+
const stack = new Error().stack;
|
|
105
|
+
if (!stack) return null;
|
|
106
|
+
|
|
107
|
+
return stack
|
|
108
|
+
.split("\n")
|
|
109
|
+
.slice(2, 8)
|
|
110
|
+
.map(line => line.trim());
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const getCctvDebugBuffer = (): CctvDebugBuffer | null => {
|
|
114
|
+
if (!isCctvDebugEnabled()) return null;
|
|
115
|
+
|
|
116
|
+
const existingBuffer = window.__UDS_CCTV_DEBUG__;
|
|
117
|
+
if (existingBuffer) return existingBuffer;
|
|
118
|
+
|
|
119
|
+
const buffer: CctvDebugBuffer = {
|
|
120
|
+
enabled: true,
|
|
121
|
+
events: [],
|
|
122
|
+
limit: DEBUG_BUFFER_LIMIT,
|
|
123
|
+
sequence: 0,
|
|
124
|
+
clear: () => {
|
|
125
|
+
buffer.events.splice(0, buffer.events.length);
|
|
126
|
+
buffer.sequence = 0;
|
|
127
|
+
},
|
|
128
|
+
dump: () => [...buffer.events],
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
window.__UDS_CCTV_DEBUG__ = buffer;
|
|
132
|
+
return buffer;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* CCTV runtime 진단 이벤트를 ring buffer와 console에 남긴다.
|
|
137
|
+
* @desc
|
|
138
|
+
* 토큰 값은 payload에 넣지 않는다. 이 logger는 debug flag가 켜진 경우에만 동작한다.
|
|
139
|
+
*/
|
|
140
|
+
export const logCctvDebugEvent = ({
|
|
141
|
+
event,
|
|
142
|
+
level = "debug",
|
|
143
|
+
payload,
|
|
144
|
+
source,
|
|
145
|
+
}: {
|
|
146
|
+
event: string;
|
|
147
|
+
level?: CctvDebugEventLevel;
|
|
148
|
+
payload?: Record<string, unknown>;
|
|
149
|
+
source: string;
|
|
150
|
+
}): void => {
|
|
151
|
+
const buffer = getCctvDebugBuffer();
|
|
152
|
+
if (!buffer) return;
|
|
153
|
+
|
|
154
|
+
const entry: CctvDebugEvent = {
|
|
155
|
+
at: new Date().toISOString(),
|
|
156
|
+
event,
|
|
157
|
+
level,
|
|
158
|
+
payload,
|
|
159
|
+
seq: buffer.sequence + 1,
|
|
160
|
+
source,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
buffer.sequence = entry.seq;
|
|
164
|
+
buffer.events.push(entry);
|
|
165
|
+
if (buffer.events.length > buffer.limit) {
|
|
166
|
+
buffer.events.splice(0, buffer.events.length - buffer.limit);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const logger =
|
|
170
|
+
level === "error"
|
|
171
|
+
? console.error
|
|
172
|
+
: level === "warn"
|
|
173
|
+
? console.warn
|
|
174
|
+
: level === "info"
|
|
175
|
+
? console.info
|
|
176
|
+
: console.debug;
|
|
177
|
+
|
|
178
|
+
logger.call(console, "[UDS:CCTV]", entry);
|
|
179
|
+
};
|
|
@@ -62,10 +62,7 @@ export function getOverlayMessage({
|
|
|
62
62
|
if (canReconnect && RTC_SESSION_ENDED_RECONNECT_STATES.has(connectionState)) {
|
|
63
63
|
return CCTV_MESSAGE.sessionEnded;
|
|
64
64
|
}
|
|
65
|
-
//
|
|
66
|
-
if (canReconnect && connectionState === "disconnected") {
|
|
67
|
-
return CCTV_MESSAGE.offline;
|
|
68
|
-
}
|
|
65
|
+
// raw disconnected는 useCctvRtcStream에서 post-connected connected로 smoothing한다.
|
|
69
66
|
if (isTokenLoading || isStreaming) return CCTV_MESSAGE.preparing;
|
|
70
67
|
if (RTC_PREPARING_STATES.has(connectionState)) return CCTV_MESSAGE.preparing;
|
|
71
68
|
return null;
|