@uniai-fe/uds-templates 0.6.5 → 0.6.7

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 CHANGED
@@ -110,6 +110,7 @@
110
110
  --cctv-live-state-dot-off: var(--color-border-strong);
111
111
  --cctv-live-state-text-off: var(--color-label-disabled);
112
112
  /* Error */
113
+ --cctv-status-text-color: var(--color-common-99);
113
114
  --cctv-error-text-color: var(--color-label-disabled);
114
115
  --cctv-error-icon-color: var(--color-label-disabled);
115
116
  /* Pagination */
@@ -1795,9 +1796,26 @@
1795
1796
  flex-direction: column;
1796
1797
  align-items: center;
1797
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;
1798
1815
  }
1799
1816
 
1800
1817
  .cctv-video-error-icon {
1818
+ display: none;
1801
1819
  margin-bottom: 2px;
1802
1820
  fill: var(--cctv-error-icon-color);
1803
1821
  }
@@ -1806,6 +1824,20 @@
1806
1824
  font-size: 13px;
1807
1825
  color: var(--cctv-error-text-color);
1808
1826
  line-height: 1.5em;
1827
+ font-weight: 500;
1828
+ }
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
+ }
1836
+ .cctv-video-overlay-body.is-error .cctv-video-error-icon {
1837
+ display: block;
1838
+ }
1839
+ .cctv-video-overlay-body.is-error .cctv-video-error-message {
1840
+ color: var(--cctv-error-text-color);
1809
1841
  font-weight: 600;
1810
1842
  }
1811
1843
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniai-fe/uds-templates",
3
- "version": "0.6.5",
3
+ "version": "0.6.7",
4
4
  "description": "UNIAI Design System; UI Templates Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -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,24 @@ 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 DISCONNECTED_RECONNECT_GRACE_MS = 5000;
27
+ const DISCONNECTED_RECONNECT_STAGGER_MS = 5000;
26
28
  const TOKEN_EXPIRY_SAFETY_MS = 30 * 1000;
27
29
 
30
+ const getStableStaggerMs = (value: string): number => {
31
+ let hash = 0;
32
+
33
+ for (let i = 0; i < value.length; i += 1) {
34
+ hash =
35
+ (hash * 31 + value.charCodeAt(i)) % DISCONNECTED_RECONNECT_STAGGER_MS;
36
+ }
37
+
38
+ return hash;
39
+ };
40
+
41
+ const getDisconnectedReconnectDelayMs = (identityKey: string): number =>
42
+ DISCONNECTED_RECONNECT_GRACE_MS + getStableStaggerMs(identityKey);
43
+
28
44
  const decodeBase64Url = (value: string): string | null => {
29
45
  try {
30
46
  const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
@@ -121,6 +137,8 @@ export function useCctvRtcStream({
121
137
  // 현재 스트림 연결 절차가 진행 중인지 여부.
122
138
  const [isStreaming, setStreaming] = useState(false);
123
139
  const [hasConnected, setHasConnected] = useState(false);
140
+ const [isDisconnectedReconnectReady, setDisconnectedReconnectReady] =
141
+ useState(false);
124
142
 
125
143
  // react-hook-form 컨텍스트에서 token username과 WHEP username override를 추적한다.
126
144
  const { control } = useFormContext();
@@ -181,6 +199,34 @@ export function useCctvRtcStream({
181
199
  setHasConnected(false);
182
200
  }, [streamIdentityKey]);
183
201
 
202
+ useEffect(() => {
203
+ if (
204
+ connectionState !== "disconnected" ||
205
+ !hasConnected ||
206
+ !streamIdentityKey
207
+ ) {
208
+ setDisconnectedReconnectReady(false);
209
+ return;
210
+ }
211
+
212
+ setDisconnectedReconnectReady(false);
213
+
214
+ const timeout = setTimeout(() => {
215
+ setDisconnectedReconnectReady(true);
216
+ }, getDisconnectedReconnectDelayMs(streamIdentityKey));
217
+
218
+ return () => {
219
+ clearTimeout(timeout);
220
+ };
221
+ }, [connectionState, hasConnected, streamIdentityKey]);
222
+
223
+ const displayConnectionState =
224
+ connectionState === "disconnected" &&
225
+ hasConnected &&
226
+ !isDisconnectedReconnectReady
227
+ ? "connected"
228
+ : connectionState;
229
+
184
230
  const hasReusableRegistryStream = useMemo(() => {
185
231
  if (!streamKeyCandidate) return false;
186
232
 
@@ -331,13 +377,14 @@ export function useCctvRtcStream({
331
377
  if (isTokenLoading || isStreaming) return false;
332
378
  if (isTokenError || streamError) return true;
333
379
  if (connectionState === "failed") return true;
334
- if (connectionState === "disconnected") return true;
380
+ if (connectionState === "disconnected") return isDisconnectedReconnectReady;
335
381
  if (connectionState === "closed") return true;
336
382
  return false;
337
383
  }, [
338
384
  cam?.cam_online,
339
385
  connectionState,
340
386
  hasConnected,
387
+ isDisconnectedReconnectReady,
341
388
  isStreaming,
342
389
  isTokenError,
343
390
  isTokenLoading,
@@ -377,7 +424,7 @@ export function useCctvRtcStream({
377
424
  cam
378
425
  ? getIsLive({
379
426
  cam,
380
- connectionState,
427
+ connectionState: displayConnectionState,
381
428
  isTokenLoading,
382
429
  isTokenError,
383
430
  isStreaming,
@@ -386,7 +433,7 @@ export function useCctvRtcStream({
386
433
  : false,
387
434
  [
388
435
  cam,
389
- connectionState,
436
+ displayConnectionState,
390
437
  isTokenLoading,
391
438
  isTokenError,
392
439
  isStreaming,
@@ -431,7 +478,7 @@ export function useCctvRtcStream({
431
478
  // 호출자에게 video ref와 상태값, 토큰 쿼리 상태를 전달한다.
432
479
  return {
433
480
  videoRef,
434
- connectionState,
481
+ connectionState: displayConnectionState,
435
482
  streamError,
436
483
  isStreaming,
437
484
  isTokenLoading,
@@ -22,6 +22,7 @@
22
22
  --cctv-live-state-text-off: var(--color-label-disabled);
23
23
 
24
24
  /* Error */
25
+ --cctv-status-text-color: var(--color-common-99);
25
26
  --cctv-error-text-color: var(--color-label-disabled);
26
27
  --cctv-error-icon-color: var(--color-label-disabled);
27
28
 
@@ -137,8 +137,26 @@
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 {
159
+ display: none;
142
160
  margin-bottom: 2px;
143
161
  fill: var(--cctv-error-icon-color);
144
162
  }
@@ -146,7 +164,26 @@
146
164
  font-size: 13px;
147
165
  color: var(--cctv-error-text-color);
148
166
  line-height: 1.5em;
149
- font-weight: 600;
167
+ font-weight: 500;
168
+ }
169
+
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
+
179
+ .cctv-video-error-icon {
180
+ display: block;
181
+ }
182
+
183
+ .cctv-video-error-message {
184
+ color: var(--cctv-error-text-color);
185
+ font-weight: 600;
186
+ }
150
187
  }
151
188
 
152
189
  .cctv-video-status-text {
@@ -7,7 +7,7 @@ import type {
7
7
  export const CCTV_MESSAGE = {
8
8
  selectCam: "카메라를 선택하세요.",
9
9
  fetching: "CCTV 데이터를 불러오는 중입니다.",
10
- preparing: "스트림을 준비하고 있습니다.",
10
+ preparing: "CCTV 영상을 연결하고 있습니다.",
11
11
  sessionEnded:
12
12
  "장시간 미사용으로 연결이 종료되었습니다. 계속 확인하시려면 다시 연결해 주세요.",
13
13
  tokenError: "토큰을 발급하지 못했습니다.",
@@ -21,6 +21,10 @@ const RTC_PREPARING_STATES = new Set<RTCPeerConnectionState>([
21
21
  "closed",
22
22
  ]);
23
23
 
24
+ const RTC_SESSION_ENDED_RECONNECT_STATES = new Set<RTCPeerConnectionState>([
25
+ "closed",
26
+ ]);
27
+
24
28
  /**
25
29
  * CCTV; 스트리밍 상태에 따른 메시지 추출
26
30
  * @param {CctvVideoOverlayMessageParams} params 상태 파라미터
@@ -51,12 +55,14 @@ export function getOverlayMessage({
51
55
  }
52
56
 
53
57
  if (!cam.cam_online) return CCTV_MESSAGE.offline;
54
- if (canReconnect) return CCTV_MESSAGE.sessionEnded;
55
58
  // 에러 상태가 준비 상태와 겹칠 때는 에러 문구가 최종 표시 계약을 우선한다.
56
59
  if (isTokenError) return CCTV_MESSAGE.tokenError;
57
60
  if (streamError) return streamError;
58
- if (isTokenLoading || isStreaming) return CCTV_MESSAGE.preparing;
59
61
  if (connectionState === "failed") return CCTV_MESSAGE.offline;
62
+ if (canReconnect && RTC_SESSION_ENDED_RECONNECT_STATES.has(connectionState)) {
63
+ return CCTV_MESSAGE.sessionEnded;
64
+ }
65
+ if (isTokenLoading || isStreaming) return CCTV_MESSAGE.preparing;
60
66
  if (RTC_PREPARING_STATES.has(connectionState)) return CCTV_MESSAGE.preparing;
61
67
  return null;
62
68
  }