@uniai-fe/uds-templates 0.5.16 → 0.5.18

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniai-fe/uds-templates",
3
- "version": "0.5.16",
3
+ "version": "0.5.18",
4
4
  "description": "UNIAI Design System; UI Templates Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -419,31 +419,55 @@ export interface API_Res_LoginRole {
419
419
  }
420
420
 
421
421
  /**
422
- * 로그인 API; 응답 성공 데이터
422
+ * 로그인 API; 세션정보 응답 데이터
423
423
  * @route /auth/user/login
424
- * @property {string} access_token 엑세스 토큰
424
+ * @route /auth/user/refresh
425
425
  * @property {string} token_type 엑세스 토큰 타입 (ex, bearer)
426
+ * @property {string} access_token 엑세스 토큰
426
427
  * @property {number} expires_in 토큰 유효기간 (초 단위)
427
- * @property {API_Res_LoginUserInfo} user_info 로그인 유저 정보
428
- * @property {API_Res_LoginGroup[]} [groups] 소속 그룹 목록
429
- * @property {API_Res_LoginGroup[]} [ancestor_groups] 상위 그룹 목록
430
- * @property {API_Res_LoginRole[]} [roles] 역할 목록
428
+ * @property {string} refresh_token 리프레시 토큰
429
+ * @property {number} refresh_expires_in 리프레시 토큰 유효기간 (초 단위)
431
430
  */
432
- export interface API_Res_LoginData {
433
- /**
434
- * 엑세스 토큰
435
- */
436
- access_token: string;
431
+ export interface API_Res_LoginToken {
437
432
  /**
438
433
  * 엑세스 토큰 타입
439
434
  * - bearer
440
435
  */
441
436
  token_type: string;
437
+ /**
438
+ * 엑세스 토큰
439
+ */
440
+ access_token: string;
442
441
  /**
443
442
  * 토큰 유효기간
444
443
  * - 초 단위
445
444
  */
446
445
  expires_in: number;
446
+ /**
447
+ * 리프레시 토큰
448
+ */
449
+ refresh_token: string;
450
+ /**
451
+ * 토큰 유효기간
452
+ * - 초 단위
453
+ */
454
+ refresh_expires_in: number;
455
+ }
456
+
457
+ /**
458
+ * 로그인 API; 응답 성공 데이터
459
+ * @route /auth/user/login
460
+ * @property {string} token_type 엑세스 토큰 타입 (ex, bearer)
461
+ * @property {string} access_token 엑세스 토큰
462
+ * @property {number} expires_in 토큰 유효기간 (초 단위)
463
+ * @property {string} refresh_token 리프레시 토큰
464
+ * @property {number} refresh_expires_in 리프레시 토큰 유효기간 (초 단위)
465
+ * @property {API_Res_LoginUserInfo} user_info 로그인 유저 정보
466
+ * @property {API_Res_LoginGroup[]} [groups] 소속 그룹 목록
467
+ * @property {API_Res_LoginGroup[]} [ancestor_groups] 상위 그룹 목록
468
+ * @property {API_Res_LoginRole[]} [roles] 역할 목록
469
+ */
470
+ export interface API_Res_LoginData extends API_Res_LoginToken {
447
471
  /**
448
472
  * 로그인 유저 정보
449
473
  */
@@ -57,7 +57,13 @@ export default function CCTVCamListItem({
57
57
  footerOptions={{ activeTitle: true, activeOpenButton: true, cam }}
58
58
  // 변경 설명: list item custom overlay는 template seam 하나만 따라간다.
59
59
  renderOverlay={renderOverlay}
60
- {...{ isError, overlayMessage, isLive }}
60
+ {...{
61
+ isError,
62
+ overlayMessage,
63
+ isLive,
64
+ canReconnect: rtcCtx.canReconnect,
65
+ reconnectStream: rtcCtx.reconnectStream,
66
+ }}
61
67
  />
62
68
  </li>
63
69
  );
@@ -59,7 +59,13 @@ export default function CCTVPaginationListItem({
59
59
  title: cam.cam_name,
60
60
  }}
61
61
  footerOptions={{ cam }}
62
- {...{ isError, overlayMessage, isLive }}
62
+ {...{
63
+ isError,
64
+ overlayMessage,
65
+ isLive,
66
+ canReconnect: rtcCtx.canReconnect,
67
+ reconnectStream: rtcCtx.reconnectStream,
68
+ }}
63
69
  />
64
70
  </button>
65
71
  </li>
@@ -18,6 +18,8 @@ import type { CctvVideoTemplateProps } from "../../types/props";
18
18
  * @property {React.ReactNode} [overlayMessage]
19
19
  * @property {boolean} [isLive]
20
20
  * @property {CctvCompanyCameraData} [cam]
21
+ * @property {boolean} [canReconnect]
22
+ * @property {CctvRtcReconnectTrigger} [reconnectStream]
21
23
  * @property {CctvVideoRenderOverlay} [renderOverlay]
22
24
  * @property {React.Ref<HTMLVideoElement>} ref
23
25
  */
@@ -31,6 +33,8 @@ const CCTVVideoTemplate = forwardRef<HTMLVideoElement, CctvVideoTemplateProps>(
31
33
  isError,
32
34
  isLive,
33
35
  overlayMessage,
36
+ canReconnect,
37
+ reconnectStream,
34
38
  renderOverlay,
35
39
  },
36
40
  ref,
@@ -56,6 +60,8 @@ const CCTVVideoTemplate = forwardRef<HTMLVideoElement, CctvVideoTemplateProps>(
56
60
  isError,
57
61
  isLive,
58
62
  overlayMessage,
63
+ canReconnect,
64
+ reconnectStream,
59
65
  })
60
66
  : defaultOverlay;
61
67
 
@@ -57,7 +57,13 @@ export default function CCTVViewerDesktopVideo({
57
57
  footerOptions={{ activeTitle: false }}
58
58
  // 변경 설명: viewer video는 service custom overlay를 직접 수용하는 첫 public entry다.
59
59
  renderOverlay={renderOverlay}
60
- {...{ isError, overlayMessage, isLive }}
60
+ {...{
61
+ isError,
62
+ overlayMessage,
63
+ isLive,
64
+ canReconnect: rtcCtx.canReconnect,
65
+ reconnectStream: rtcCtx.reconnectStream,
66
+ }}
61
67
  />
62
68
  );
63
69
  }
@@ -10,11 +10,17 @@ import {
10
10
  useCctvApiUrl,
11
11
  useCctvRtcStreamRegistry,
12
12
  } from "../components/Provider";
13
- import type { UseCctvRtcStreamParams, UseCctvRtcStreamReturn } from "../types";
13
+ import type {
14
+ CctvRtcReconnectTrigger,
15
+ UseCctvRtcStreamParams,
16
+ UseCctvRtcStreamReturn,
17
+ } from "../types";
14
18
  import { cctvRtcLiveRegistryAtom } from "../jotai/rtc";
15
19
  import { getIsLive } from "../utils/video-state";
16
20
  import { useFormContext, useWatch } from "react-hook-form";
17
21
 
22
+ const AUTO_RECONNECT_INTERVAL_MS = 3000;
23
+
18
24
  /**
19
25
  * CCTV 영상 스트림을 WebRTC로 연결하는 커스텀 훅.
20
26
  * @hook
@@ -31,7 +37,9 @@ import { useFormContext, useWatch } from "react-hook-form";
31
37
  * isStreaming, // 현재 스트림 연결 절차가 진행 중인지 여부
32
38
  * isTokenLoading, // 토큰 발급 요청이 진행 중인지 여부
33
39
  * isTokenError, // 토큰 발급 요청이 실패했는지 여부
40
+ * canReconnect, // 재연결 가능한 종료 상태 여부
34
41
  * refetchToken, // 토큰 발급을 재시도하는 함수
42
+ * reconnectStream, // 재연결 trigger 함수
35
43
  * }
36
44
  */
37
45
  export function useCctvRtcStream({
@@ -51,6 +59,7 @@ export function useCctvRtcStream({
51
59
  const videoRef = useRef<HTMLVideoElement | null>(null);
52
60
  const activeStreamKeyRef = useRef<string | null>(null);
53
61
  const activeStreamIdentityKeyRef = useRef<string | null>(null);
62
+ const lastAutoReconnectAtRef = useRef(0);
54
63
 
55
64
  // RTCPeerConnectionState를 관찰해 UI에 노출하기 위한 상태값.
56
65
  const [connectionState, setConnectionState] =
@@ -61,6 +70,7 @@ export function useCctvRtcStream({
61
70
 
62
71
  // 현재 스트림 연결 절차가 진행 중인지 여부.
63
72
  const [isStreaming, setStreaming] = useState(false);
73
+ const [hasConnected, setHasConnected] = useState(false);
64
74
 
65
75
  // react-hook-form 컨텍스트에서 username을 추적한다.
66
76
  const { control } = useFormContext();
@@ -74,6 +84,7 @@ export function useCctvRtcStream({
74
84
  url: tokenUrl ?? contextTokenUrl,
75
85
  });
76
86
 
87
+ const { refetch: refetchRtcToken } = tokenQuery;
77
88
  const isTokenLoading = tokenQuery.isFetching;
78
89
  const isTokenError = tokenQuery.isError;
79
90
 
@@ -111,6 +122,10 @@ export function useCctvRtcStream({
111
122
  return [username, cam.company_id, cam.cam_id, endpoint].join("|");
112
123
  }, [cam?.cam_id, cam?.company_id, endpoint, username]);
113
124
 
125
+ useEffect(() => {
126
+ setHasConnected(false);
127
+ }, [streamIdentityKey]);
128
+
114
129
  // 토큰과 endpoint가 준비되면 WebRTC 스트림을 연결한다.
115
130
  useEffect(() => {
116
131
  const currentVideo = videoRef.current;
@@ -154,6 +169,7 @@ export function useCctvRtcStream({
154
169
  setConnectionState(snapshot.connectionState);
155
170
  setStreamError(snapshot.streamError);
156
171
  setStreaming(snapshot.isStreaming);
172
+ if (snapshot.connectionState === "connected") setHasConnected(true);
157
173
  if (snapshot.stream && currentVideo.srcObject !== snapshot.stream) {
158
174
  currentVideo.srcObject = snapshot.stream;
159
175
  }
@@ -162,6 +178,7 @@ export function useCctvRtcStream({
162
178
  setConnectionState(snapshot.connectionState);
163
179
  setStreamError(snapshot.streamError);
164
180
  setStreaming(snapshot.isStreaming);
181
+ if (snapshot.connectionState === "connected") setHasConnected(true);
165
182
 
166
183
  if (snapshot.stream && currentVideo.srcObject !== snapshot.stream) {
167
184
  currentVideo.srcObject = snapshot.stream;
@@ -182,17 +199,65 @@ export function useCctvRtcStream({
182
199
  cam?.cam_online,
183
200
  ]);
184
201
 
185
- const refetchToken = useCallback<typeof tokenQuery.refetch>(
202
+ const reconnectStream = useCallback<CctvRtcReconnectTrigger>(
186
203
  async options => {
187
- const result = await tokenQuery.refetch(options);
204
+ const result = await refetchRtcToken(options);
188
205
  if (result.isSuccess && streamIdentityKey) {
189
206
  streamRegistry.closeByIdentity(streamIdentityKey);
190
207
  }
191
208
  return result;
192
209
  },
193
- [streamIdentityKey, streamRegistry, tokenQuery],
210
+ [refetchRtcToken, streamIdentityKey, streamRegistry],
194
211
  );
195
212
 
213
+ const canReconnect = useMemo(() => {
214
+ if (!cam?.cam_online) return false;
215
+ if (!hasConnected) return false;
216
+ if (!streamIdentityKey) return false;
217
+ if (isTokenLoading || isStreaming) return false;
218
+ if (isTokenError || streamError) return true;
219
+ if (connectionState === "failed") return true;
220
+ if (connectionState === "disconnected") return true;
221
+ if (connectionState === "closed") return true;
222
+ return false;
223
+ }, [
224
+ cam?.cam_online,
225
+ connectionState,
226
+ hasConnected,
227
+ isStreaming,
228
+ isTokenError,
229
+ isTokenLoading,
230
+ streamError,
231
+ streamIdentityKey,
232
+ ]);
233
+
234
+ const refetchToken = reconnectStream;
235
+
236
+ useEffect(() => {
237
+ if (typeof window === "undefined" || typeof document === "undefined")
238
+ return;
239
+
240
+ const reconnectOnFocus = () => {
241
+ if (document.visibilityState !== "visible") return;
242
+ if (!canReconnect) return;
243
+
244
+ const now = Date.now();
245
+ if (now - lastAutoReconnectAtRef.current < AUTO_RECONNECT_INTERVAL_MS)
246
+ return;
247
+
248
+ lastAutoReconnectAtRef.current = now;
249
+ void reconnectStream();
250
+ };
251
+
252
+ window.addEventListener("focus", reconnectOnFocus);
253
+ document.addEventListener("visibilitychange", reconnectOnFocus);
254
+
255
+ return () => {
256
+ window.removeEventListener("focus", reconnectOnFocus);
257
+ document.removeEventListener("visibilitychange", reconnectOnFocus);
258
+ };
259
+ }, [canReconnect, reconnectStream]);
260
+
196
261
  const liveState = useMemo(
197
262
  () =>
198
263
  cam
@@ -257,6 +322,8 @@ export function useCctvRtcStream({
257
322
  isStreaming,
258
323
  isTokenLoading,
259
324
  isTokenError,
325
+ canReconnect,
260
326
  refetchToken,
327
+ reconnectStream,
261
328
  };
262
329
  }
@@ -1,4 +1,8 @@
1
- import type { UseQueryResult } from "@tanstack/react-query";
1
+ import type {
2
+ QueryObserverResult,
3
+ RefetchOptions,
4
+ UseQueryResult,
5
+ } from "@tanstack/react-query";
2
6
  import type { API_Res_CctvCompany, API_Res_CctvRtcToken } from "./api";
3
7
  import type {
4
8
  CctvCompanyCameraData,
@@ -9,6 +13,13 @@ import type {
9
13
  import type { UseFormReturn } from "react-hook-form";
10
14
  import type { CctvBaseContext } from "./context";
11
15
 
16
+ /**
17
+ * CCTV; RTC stream 재연결 trigger
18
+ */
19
+ export type CctvRtcReconnectTrigger = (
20
+ options?: RefetchOptions,
21
+ ) => Promise<QueryObserverResult<API_Res_CctvRtcToken>>;
22
+
12
23
  /**
13
24
  * CCTV; useCctvRtcStream params
14
25
  * @property {CctvCompanyCameraData} [cam] 스트리밍 카메라 정보
@@ -77,17 +88,27 @@ export interface UseCctvRtcStreamState extends UseCctvRtcStreamError {
77
88
  * @property {boolean} isStreaming startWhepStream 진행 여부
78
89
  * @property {boolean} isTokenLoading 토큰 발급 요청 진행 여부
79
90
  * @property {boolean} isTokenError 토큰 발급 실패 여부
91
+ * @property {boolean} canReconnect 재연결 가능한 종료 상태 여부
80
92
  * @property {() => Promise<void>} refetchToken 토큰 재발급 함수
93
+ * @property {() => Promise<QueryObserverResult<API_Res_CctvRtcToken>>} reconnectStream 재연결 trigger 함수
81
94
  */
82
95
  export interface UseCctvRtcStreamReturn extends UseCctvRtcStreamState {
83
96
  /**
84
97
  * <video /> ref
85
98
  */
86
99
  videoRef: React.RefObject<HTMLVideoElement | null>;
100
+ /**
101
+ * 재연결 가능한 종료 상태 여부
102
+ */
103
+ canReconnect: boolean;
87
104
  /**
88
105
  * 토큰 재발급 함수
89
106
  */
90
- refetchToken: UseQueryResult<API_Res_CctvRtcToken>["refetch"];
107
+ refetchToken: CctvRtcReconnectTrigger;
108
+ /**
109
+ * 재연결 trigger 함수
110
+ */
111
+ reconnectStream: CctvRtcReconnectTrigger;
91
112
  }
92
113
 
93
114
  /**
@@ -1,4 +1,5 @@
1
1
  import type { CctvCompanyCameraData, CctvCompanyCameraList } from "./list";
2
+ import type { CctvRtcReconnectTrigger } from "./hook";
2
3
 
3
4
  /**
4
5
  * CCTV; custom overlay render context
@@ -8,6 +9,8 @@ import type { CctvCompanyCameraData, CctvCompanyCameraList } from "./list";
8
9
  * @property {boolean | undefined} [isError] 영상 에러 상태 여부
9
10
  * @property {boolean | undefined} [isLive] 영상 live 상태 여부
10
11
  * @property {React.ReactNode} [overlayMessage] 기본 안내/에러 메시지 콘텐츠
12
+ * @property {boolean | undefined} [canReconnect] 재연결 가능한 종료 상태 여부
13
+ * @property {CctvRtcReconnectTrigger | undefined} [reconnectStream] 재연결 trigger 함수
11
14
  */
12
15
  export interface CctvVideoRenderOverlayContext {
13
16
  /**
@@ -34,6 +37,14 @@ export interface CctvVideoRenderOverlayContext {
34
37
  * 기본 안내/에러 메시지 콘텐츠
35
38
  */
36
39
  overlayMessage?: React.ReactNode;
40
+ /**
41
+ * 재연결 가능한 종료 상태 여부
42
+ */
43
+ canReconnect?: boolean;
44
+ /**
45
+ * 재연결 trigger 함수
46
+ */
47
+ reconnectStream?: CctvRtcReconnectTrigger;
37
48
  }
38
49
 
39
50
  /**
@@ -115,6 +126,8 @@ export interface CctvVideoOverlayFooterProps {
115
126
  * @property {boolean} [isError] 에러 상태 여부
116
127
  * @property {boolean} [isLive] 영상이 정상적으로 재생 중인지 여부
117
128
  * @property {React.ReactNode} [overlayMessage] 에러/안내 메시지 콘텐츠
129
+ * @property {boolean} [canReconnect] 재연결 가능한 종료 상태 여부
130
+ * @property {CctvRtcReconnectTrigger} [reconnectStream] 재연결 trigger 함수
118
131
  */
119
132
  export interface CctvVideoStateProps {
120
133
  /**
@@ -129,6 +142,14 @@ export interface CctvVideoStateProps {
129
142
  * 오버레이 메시지 콘텐츠
130
143
  */
131
144
  overlayMessage?: React.ReactNode;
145
+ /**
146
+ * 재연결 가능한 종료 상태 여부
147
+ */
148
+ canReconnect?: boolean;
149
+ /**
150
+ * 재연결 trigger 함수
151
+ */
152
+ reconnectStream?: CctvRtcReconnectTrigger;
132
153
  }
133
154
 
134
155
  /**
@@ -11,6 +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
15
  */
15
16
  export interface CctvVideoOverlayMessageParams extends UseCctvRtcStreamState {
16
17
  /**
@@ -25,6 +26,10 @@ export interface CctvVideoOverlayMessageParams extends UseCctvRtcStreamState {
25
26
  * 서버에서 cam 리스트를 가져오는 중인지 여부
26
27
  */
27
28
  isFetching?: boolean;
29
+ /**
30
+ * 재연결 가능한 종료 상태 여부
31
+ */
32
+ canReconnect?: boolean;
28
33
  }
29
34
 
30
35
  /**
@@ -48,10 +53,15 @@ export interface CctvVideoLiveParams extends UseCctvRtcStreamState {
48
53
  * @property {CctvCompanyCameraData} [cam] 카메라 데이터
49
54
  * @property {boolean} isTokenError 토큰 발급 실패 여부
50
55
  * @property {string | null} streamError 스트림 오류 메시지
56
+ * @property {boolean} [canReconnect] 재연결 가능한 종료 상태 여부
51
57
  */
52
58
  export interface CctvVideoErrorParams extends UseCctvRtcStreamError {
53
59
  /**
54
60
  * 카메라 데이터
55
61
  */
56
62
  cam?: CctvCompanyCameraData;
63
+ /**
64
+ * 재연결 가능한 종료 상태 여부
65
+ */
66
+ canReconnect?: boolean;
57
67
  }
@@ -8,6 +8,8 @@ export const CCTV_MESSAGE = {
8
8
  selectCam: "카메라를 선택하세요.",
9
9
  fetching: "CCTV 데이터를 불러오는 중입니다.",
10
10
  preparing: "스트림을 준비하고 있습니다.",
11
+ sessionEnded:
12
+ "장시간 미사용으로 연결이 종료되었습니다. 계속 확인하시려면 다시 연결해 주세요.",
11
13
  tokenError: "토큰을 발급하지 못했습니다.",
12
14
  offline: "CCTV 연결 오류",
13
15
  } as const;
@@ -34,6 +36,7 @@ const RTC_PREPARING_STATES = new Set<RTCPeerConnectionState>([
34
36
  */
35
37
  export function getOverlayMessage({
36
38
  cam,
39
+ canReconnect,
37
40
  connectionState,
38
41
  hasCamProp = false,
39
42
  isFetching = false,
@@ -48,6 +51,7 @@ export function getOverlayMessage({
48
51
  }
49
52
 
50
53
  if (!cam.cam_online) return CCTV_MESSAGE.offline;
54
+ if (canReconnect) return CCTV_MESSAGE.sessionEnded;
51
55
  // 에러 상태가 준비 상태와 겹칠 때는 에러 문구가 최종 표시 계약을 우선한다.
52
56
  if (isTokenError) return CCTV_MESSAGE.tokenError;
53
57
  if (streamError) return streamError;
@@ -95,11 +99,13 @@ export function getIsLive({
95
99
  */
96
100
  export function getIsError({
97
101
  cam,
102
+ canReconnect,
98
103
  isTokenError,
99
104
  streamError,
100
105
  }: CctvVideoErrorParams): boolean {
101
106
  if (!cam) return true;
102
107
  if (!cam.cam_online) return true;
108
+ if (canReconnect) return true;
103
109
  if (isTokenError) return true;
104
110
  if (streamError) return true;
105
111
  return false;