@uniai-fe/uds-templates 0.6.6 → 0.6.8
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/dist/styles.css +23 -1
- package/package.json +1 -1
- package/src/cctv/components/video/overlay/body/Error.tsx +4 -0
- package/src/cctv/hooks/useRtcStream.ts +121 -12
- package/src/cctv/styles/video.scss +26 -1
- package/src/cctv/types/hook.ts +5 -2
- package/src/cctv/types/props.ts +7 -4
- package/src/cctv/types/video-state.ts +4 -4
- package/src/cctv/utils/video-state.ts +4 -0
package/dist/styles.css
CHANGED
|
@@ -1796,6 +1796,22 @@
|
|
|
1796
1796
|
flex-direction: column;
|
|
1797
1797
|
align-items: center;
|
|
1798
1798
|
justify-content: center;
|
|
1799
|
+
gap: 2px;
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
.cctv-video-loading-icon {
|
|
1803
|
+
flex: 0 0 auto;
|
|
1804
|
+
width: 24px;
|
|
1805
|
+
height: 24px;
|
|
1806
|
+
display: flex;
|
|
1807
|
+
color: var(--cctv-error-text-color);
|
|
1808
|
+
}
|
|
1809
|
+
.cctv-video-loading-icon .alternate-loading-icon {
|
|
1810
|
+
width: 24px;
|
|
1811
|
+
height: 24px;
|
|
1812
|
+
}
|
|
1813
|
+
.cctv-video-loading-icon svg stop:first-child {
|
|
1814
|
+
stop-color: currentColor;
|
|
1799
1815
|
}
|
|
1800
1816
|
|
|
1801
1817
|
.cctv-video-error-icon {
|
|
@@ -1806,11 +1822,17 @@
|
|
|
1806
1822
|
|
|
1807
1823
|
.cctv-video-error-message {
|
|
1808
1824
|
font-size: 13px;
|
|
1809
|
-
color: var(--cctv-
|
|
1825
|
+
color: var(--cctv-error-text-color);
|
|
1810
1826
|
line-height: 1.5em;
|
|
1811
1827
|
font-weight: 500;
|
|
1812
1828
|
}
|
|
1813
1829
|
|
|
1830
|
+
.cctv-video-overlay-body.is-error .cctv-video-error {
|
|
1831
|
+
gap: 0;
|
|
1832
|
+
}
|
|
1833
|
+
.cctv-video-overlay-body.is-error .cctv-video-loading-icon {
|
|
1834
|
+
display: none;
|
|
1835
|
+
}
|
|
1814
1836
|
.cctv-video-overlay-body.is-error .cctv-video-error-icon {
|
|
1815
1837
|
display: block;
|
|
1816
1838
|
}
|
package/package.json
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Alternate } from "@uniai-fe/uds-primitives";
|
|
1
2
|
import CCTVErrorIcon from "../../../../img/error.svg";
|
|
2
3
|
|
|
3
4
|
export default function CCTVVideoError({
|
|
@@ -7,6 +8,9 @@ export default function CCTVVideoError({
|
|
|
7
8
|
}) {
|
|
8
9
|
return (
|
|
9
10
|
<div className="cctv-video-error">
|
|
11
|
+
<span className="cctv-video-loading-icon" aria-hidden="true">
|
|
12
|
+
<Alternate.LoadingIcon size="small" />
|
|
13
|
+
</span>
|
|
10
14
|
<figure className="cctv-video-error-icon">
|
|
11
15
|
<CCTVErrorIcon width={24} height={24} />
|
|
12
16
|
</figure>
|
|
@@ -23,8 +23,65 @@ import { getIsLive } from "../utils/video-state";
|
|
|
23
23
|
import { useFormContext, useWatch } from "react-hook-form";
|
|
24
24
|
|
|
25
25
|
const AUTO_RECONNECT_INTERVAL_MS = 3000;
|
|
26
|
+
const POST_CONNECTED_RECONNECT_GRACE_MS = 5000;
|
|
27
|
+
const POST_CONNECTED_RECONNECT_STAGGER_MS = 5000;
|
|
26
28
|
const TOKEN_EXPIRY_SAFETY_MS = 30 * 1000;
|
|
27
29
|
|
|
30
|
+
type CctvRtcReconnectReason =
|
|
31
|
+
| "tokenError"
|
|
32
|
+
| "streamError"
|
|
33
|
+
| "failed"
|
|
34
|
+
| "disconnected";
|
|
35
|
+
|
|
36
|
+
const DISPLAY_CONNECTED_DURING_GRACE_STATES = new Set<RTCPeerConnectionState>([
|
|
37
|
+
"disconnected",
|
|
38
|
+
"failed",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 같은 회사/카메라 묶음이 동시에 재연결하지 않도록 identity 기반 지연값을 만든다.
|
|
43
|
+
* @desc
|
|
44
|
+
* 이 값은 보안용 난수가 아니라 UI/네트워크 부하를 분산하기 위한 안정 해시다.
|
|
45
|
+
*/
|
|
46
|
+
const getStableStaggerMs = (value: string): number => {
|
|
47
|
+
let hash = 0;
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
50
|
+
hash =
|
|
51
|
+
(hash * 31 + value.charCodeAt(i)) % POST_CONNECTED_RECONNECT_STAGGER_MS;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return hash;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 최초 연결 이후 끊김/실패가 감지됐을 때 자동 재연결 노출 전 대기시간을 계산한다.
|
|
59
|
+
*/
|
|
60
|
+
const getPostConnectedReconnectDelayMs = (delayKey: string): number =>
|
|
61
|
+
POST_CONNECTED_RECONNECT_GRACE_MS + getStableStaggerMs(delayKey);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 재연결이 필요한 장애 사유를 UDS 내부 기준으로 정규화한다.
|
|
65
|
+
* @desc
|
|
66
|
+
* `closed`는 `streamRegistry.closeByIdentity()`처럼 UDS가 의도적으로 닫을 때도 발생하므로
|
|
67
|
+
* 자동 재연결 사유에 포함하지 않는다.
|
|
68
|
+
*/
|
|
69
|
+
const getPostConnectedReconnectReason = ({
|
|
70
|
+
connectionState,
|
|
71
|
+
isTokenError,
|
|
72
|
+
streamError,
|
|
73
|
+
}: {
|
|
74
|
+
connectionState: RTCPeerConnectionState;
|
|
75
|
+
isTokenError: boolean;
|
|
76
|
+
streamError: string | null;
|
|
77
|
+
}): CctvRtcReconnectReason | null => {
|
|
78
|
+
if (isTokenError) return "tokenError";
|
|
79
|
+
if (streamError) return "streamError";
|
|
80
|
+
if (connectionState === "failed") return "failed";
|
|
81
|
+
if (connectionState === "disconnected") return "disconnected";
|
|
82
|
+
return null;
|
|
83
|
+
};
|
|
84
|
+
|
|
28
85
|
const decodeBase64Url = (value: string): string | null => {
|
|
29
86
|
try {
|
|
30
87
|
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
@@ -86,7 +143,7 @@ const canUseTokenForNewConnection = ({
|
|
|
86
143
|
* isStreaming, // 현재 스트림 연결 절차가 진행 중인지 여부
|
|
87
144
|
* isTokenLoading, // 토큰 발급 요청이 진행 중인지 여부
|
|
88
145
|
* isTokenError, // 토큰 발급 요청이 실패했는지 여부
|
|
89
|
-
* canReconnect, // 재연결
|
|
146
|
+
* canReconnect, // UDS 내부 grace/stagger 이후 재연결 호출이 가능한지 여부
|
|
90
147
|
* refetchToken, // 토큰 발급을 재시도하는 함수
|
|
91
148
|
* reconnectStream, // 재연결 trigger 함수
|
|
92
149
|
* }
|
|
@@ -121,6 +178,8 @@ export function useCctvRtcStream({
|
|
|
121
178
|
// 현재 스트림 연결 절차가 진행 중인지 여부.
|
|
122
179
|
const [isStreaming, setStreaming] = useState(false);
|
|
123
180
|
const [hasConnected, setHasConnected] = useState(false);
|
|
181
|
+
const [isPostConnectedReconnectReady, setPostConnectedReconnectReady] =
|
|
182
|
+
useState(false);
|
|
124
183
|
|
|
125
184
|
// react-hook-form 컨텍스트에서 token username과 WHEP username override를 추적한다.
|
|
126
185
|
const { control } = useFormContext();
|
|
@@ -181,6 +240,60 @@ export function useCctvRtcStream({
|
|
|
181
240
|
setHasConnected(false);
|
|
182
241
|
}, [streamIdentityKey]);
|
|
183
242
|
|
|
243
|
+
const reconnectReason = useMemo(
|
|
244
|
+
() =>
|
|
245
|
+
getPostConnectedReconnectReason({
|
|
246
|
+
connectionState,
|
|
247
|
+
isTokenError,
|
|
248
|
+
streamError,
|
|
249
|
+
}),
|
|
250
|
+
[connectionState, isTokenError, streamError],
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
const canStartReconnectTimer =
|
|
255
|
+
hasConnected &&
|
|
256
|
+
Boolean(streamIdentityKey) &&
|
|
257
|
+
Boolean(reconnectReason) &&
|
|
258
|
+
!isTokenLoading &&
|
|
259
|
+
!isStreaming;
|
|
260
|
+
|
|
261
|
+
if (!canStartReconnectTimer) {
|
|
262
|
+
setPostConnectedReconnectReady(false);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
setPostConnectedReconnectReady(false);
|
|
267
|
+
|
|
268
|
+
const timeout = setTimeout(
|
|
269
|
+
() => {
|
|
270
|
+
setPostConnectedReconnectReady(true);
|
|
271
|
+
},
|
|
272
|
+
getPostConnectedReconnectDelayMs(
|
|
273
|
+
`${streamIdentityKey}|${reconnectReason}`,
|
|
274
|
+
),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
return () => {
|
|
278
|
+
clearTimeout(timeout);
|
|
279
|
+
};
|
|
280
|
+
}, [
|
|
281
|
+
hasConnected,
|
|
282
|
+
isStreaming,
|
|
283
|
+
isTokenLoading,
|
|
284
|
+
reconnectReason,
|
|
285
|
+
streamIdentityKey,
|
|
286
|
+
]);
|
|
287
|
+
|
|
288
|
+
const shouldPreserveConnectedDisplay =
|
|
289
|
+
hasConnected &&
|
|
290
|
+
!isPostConnectedReconnectReady &&
|
|
291
|
+
DISPLAY_CONNECTED_DURING_GRACE_STATES.has(connectionState);
|
|
292
|
+
|
|
293
|
+
const displayConnectionState = shouldPreserveConnectedDisplay
|
|
294
|
+
? "connected"
|
|
295
|
+
: connectionState;
|
|
296
|
+
|
|
184
297
|
const hasReusableRegistryStream = useMemo(() => {
|
|
185
298
|
if (!streamKeyCandidate) return false;
|
|
186
299
|
|
|
@@ -329,19 +442,15 @@ export function useCctvRtcStream({
|
|
|
329
442
|
if (!hasConnected) return false;
|
|
330
443
|
if (!streamIdentityKey) return false;
|
|
331
444
|
if (isTokenLoading || isStreaming) return false;
|
|
332
|
-
if (
|
|
333
|
-
|
|
334
|
-
if (connectionState === "disconnected") return true;
|
|
335
|
-
if (connectionState === "closed") return true;
|
|
336
|
-
return false;
|
|
445
|
+
if (!reconnectReason) return false;
|
|
446
|
+
return isPostConnectedReconnectReady;
|
|
337
447
|
}, [
|
|
338
448
|
cam?.cam_online,
|
|
339
|
-
connectionState,
|
|
340
449
|
hasConnected,
|
|
450
|
+
isPostConnectedReconnectReady,
|
|
341
451
|
isStreaming,
|
|
342
|
-
isTokenError,
|
|
343
452
|
isTokenLoading,
|
|
344
|
-
|
|
453
|
+
reconnectReason,
|
|
345
454
|
streamIdentityKey,
|
|
346
455
|
]);
|
|
347
456
|
|
|
@@ -377,7 +486,7 @@ export function useCctvRtcStream({
|
|
|
377
486
|
cam
|
|
378
487
|
? getIsLive({
|
|
379
488
|
cam,
|
|
380
|
-
connectionState,
|
|
489
|
+
connectionState: displayConnectionState,
|
|
381
490
|
isTokenLoading,
|
|
382
491
|
isTokenError,
|
|
383
492
|
isStreaming,
|
|
@@ -386,7 +495,7 @@ export function useCctvRtcStream({
|
|
|
386
495
|
: false,
|
|
387
496
|
[
|
|
388
497
|
cam,
|
|
389
|
-
|
|
498
|
+
displayConnectionState,
|
|
390
499
|
isTokenLoading,
|
|
391
500
|
isTokenError,
|
|
392
501
|
isStreaming,
|
|
@@ -431,7 +540,7 @@ export function useCctvRtcStream({
|
|
|
431
540
|
// 호출자에게 video ref와 상태값, 토큰 쿼리 상태를 전달한다.
|
|
432
541
|
return {
|
|
433
542
|
videoRef,
|
|
434
|
-
connectionState,
|
|
543
|
+
connectionState: displayConnectionState,
|
|
435
544
|
streamError,
|
|
436
545
|
isStreaming,
|
|
437
546
|
isTokenLoading,
|
|
@@ -137,6 +137,23 @@
|
|
|
137
137
|
flex-direction: column;
|
|
138
138
|
align-items: center;
|
|
139
139
|
justify-content: center;
|
|
140
|
+
gap: 2px;
|
|
141
|
+
}
|
|
142
|
+
.cctv-video-loading-icon {
|
|
143
|
+
flex: 0 0 auto;
|
|
144
|
+
width: 24px;
|
|
145
|
+
height: 24px;
|
|
146
|
+
display: flex;
|
|
147
|
+
color: var(--cctv-error-text-color);
|
|
148
|
+
|
|
149
|
+
.alternate-loading-icon {
|
|
150
|
+
width: 24px;
|
|
151
|
+
height: 24px;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
svg stop:first-child {
|
|
155
|
+
stop-color: currentColor;
|
|
156
|
+
}
|
|
140
157
|
}
|
|
141
158
|
.cctv-video-error-icon {
|
|
142
159
|
display: none;
|
|
@@ -145,12 +162,20 @@
|
|
|
145
162
|
}
|
|
146
163
|
.cctv-video-error-message {
|
|
147
164
|
font-size: 13px;
|
|
148
|
-
color: var(--cctv-
|
|
165
|
+
color: var(--cctv-error-text-color);
|
|
149
166
|
line-height: 1.5em;
|
|
150
167
|
font-weight: 500;
|
|
151
168
|
}
|
|
152
169
|
|
|
153
170
|
.cctv-video-overlay-body.is-error {
|
|
171
|
+
.cctv-video-error {
|
|
172
|
+
gap: 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.cctv-video-loading-icon {
|
|
176
|
+
display: none;
|
|
177
|
+
}
|
|
178
|
+
|
|
154
179
|
.cctv-video-error-icon {
|
|
155
180
|
display: block;
|
|
156
181
|
}
|
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 재연결
|
|
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,7 +98,10 @@ export interface UseCctvRtcStreamReturn extends UseCctvRtcStreamState {
|
|
|
98
98
|
*/
|
|
99
99
|
videoRef: React.RefObject<HTMLVideoElement | null>;
|
|
100
100
|
/**
|
|
101
|
-
* 재연결
|
|
101
|
+
* UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
102
|
+
* @desc
|
|
103
|
+
* 스트림 장애 감지 즉시 true가 되는 플래그가 아니라,
|
|
104
|
+
* 서비스 overlay가 reconnectStream()을 호출해도 되는 시점에 true가 된다.
|
|
102
105
|
*/
|
|
103
106
|
canReconnect: boolean;
|
|
104
107
|
/**
|
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] 재연결
|
|
12
|
+
* @property {boolean | undefined} [canReconnect] UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
13
13
|
* @property {CctvRtcReconnectTrigger | undefined} [reconnectStream] 재연결 trigger 함수
|
|
14
14
|
*/
|
|
15
15
|
export interface CctvVideoRenderOverlayContext {
|
|
@@ -38,7 +38,10 @@ export interface CctvVideoRenderOverlayContext {
|
|
|
38
38
|
*/
|
|
39
39
|
overlayMessage?: React.ReactNode;
|
|
40
40
|
/**
|
|
41
|
-
* 재연결
|
|
41
|
+
* UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
42
|
+
* @desc
|
|
43
|
+
* custom overlay에서 자동 재연결을 구현할 때 이 값이 true인 경우에만
|
|
44
|
+
* reconnectStream()을 호출해야 한다.
|
|
42
45
|
*/
|
|
43
46
|
canReconnect?: boolean;
|
|
44
47
|
/**
|
|
@@ -126,7 +129,7 @@ export interface CctvVideoOverlayFooterProps {
|
|
|
126
129
|
* @property {boolean} [isError] 에러 상태 여부
|
|
127
130
|
* @property {boolean} [isLive] 영상이 정상적으로 재생 중인지 여부
|
|
128
131
|
* @property {React.ReactNode} [overlayMessage] 에러/안내 메시지 콘텐츠
|
|
129
|
-
* @property {boolean} [canReconnect] 재연결
|
|
132
|
+
* @property {boolean} [canReconnect] UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
130
133
|
* @property {CctvRtcReconnectTrigger} [reconnectStream] 재연결 trigger 함수
|
|
131
134
|
*/
|
|
132
135
|
export interface CctvVideoStateProps {
|
|
@@ -143,7 +146,7 @@ export interface CctvVideoStateProps {
|
|
|
143
146
|
*/
|
|
144
147
|
overlayMessage?: React.ReactNode;
|
|
145
148
|
/**
|
|
146
|
-
* 재연결
|
|
149
|
+
* UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
147
150
|
*/
|
|
148
151
|
canReconnect?: boolean;
|
|
149
152
|
/**
|
|
@@ -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] 재연결
|
|
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
|
-
* 재연결
|
|
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] 재연결
|
|
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
|
-
* 재연결
|
|
64
|
+
* UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
65
65
|
*/
|
|
66
66
|
canReconnect?: boolean;
|
|
67
67
|
}
|
|
@@ -62,6 +62,10 @@ export function getOverlayMessage({
|
|
|
62
62
|
if (canReconnect && RTC_SESSION_ENDED_RECONNECT_STATES.has(connectionState)) {
|
|
63
63
|
return CCTV_MESSAGE.sessionEnded;
|
|
64
64
|
}
|
|
65
|
+
// grace/stagger가 끝난 disconnected는 더 이상 준비 상태가 아니라 재연결 대상 장애다.
|
|
66
|
+
if (canReconnect && connectionState === "disconnected") {
|
|
67
|
+
return CCTV_MESSAGE.offline;
|
|
68
|
+
}
|
|
65
69
|
if (isTokenLoading || isStreaming) return CCTV_MESSAGE.preparing;
|
|
66
70
|
if (RTC_PREPARING_STATES.has(connectionState)) return CCTV_MESSAGE.preparing;
|
|
67
71
|
return null;
|