@uniai-fe/uds-templates 0.6.26 → 0.6.28

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
@@ -94,6 +94,7 @@
94
94
  /* Card layout */
95
95
  --cctv-video-radius: 12px;
96
96
  --cctv-list-gap: var(--spacing-gap-5);
97
+ --cctv-list-item-min-width: 320px;
97
98
  --cctv-overlay-padding-x: var(--spacing-padding-8);
98
99
  --cctv-overlay-padding-y: var(--spacing-padding-6);
99
100
  --cctv-video-bg: var(--color-surface-heavy);
@@ -1873,6 +1874,7 @@
1873
1874
  }
1874
1875
 
1875
1876
  .cctv-cam-list-container {
1877
+ container: cctv-cam-list/inline-size;
1876
1878
  width: 100%;
1877
1879
  height: 100%;
1878
1880
  overflow-x: hidden;
@@ -1888,7 +1890,7 @@
1888
1890
  .cctv-cam-list-track {
1889
1891
  display: grid;
1890
1892
  width: 100%;
1891
- grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
1893
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--cctv-list-item-min-width)), 1fr));
1892
1894
  gap: var(--cctv-list-gap);
1893
1895
  padding: 0;
1894
1896
  margin: 0;
@@ -1902,6 +1904,26 @@
1902
1904
  max-height: 240px;
1903
1905
  }
1904
1906
 
1907
+ @container cctv-cam-list (min-width: 652px) {
1908
+ .cctv-cam-list-track {
1909
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1910
+ }
1911
+ }
1912
+ @container cctv-cam-list (min-width: 984px) {
1913
+ .cctv-cam-list-track {
1914
+ grid-template-columns: repeat(3, minmax(0, 1fr));
1915
+ }
1916
+ }
1917
+ @container cctv-cam-list (min-width: 1316px) {
1918
+ .cctv-cam-list-track {
1919
+ grid-template-columns: repeat(4, minmax(0, 1fr));
1920
+ }
1921
+ }
1922
+ @container cctv-cam-list (min-width: 1648px) {
1923
+ .cctv-cam-list-track {
1924
+ grid-template-columns: repeat(5, minmax(0, 1fr));
1925
+ }
1926
+ }
1905
1927
  .cctv-pagination-container {
1906
1928
  width: 100%;
1907
1929
  display: flex;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniai-fe/uds-templates",
3
- "version": "0.6.26",
3
+ "version": "0.6.28",
4
4
  "description": "UNIAI Design System; UI Templates Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -72,9 +72,9 @@
72
72
  },
73
73
  "devDependencies": {
74
74
  "@svgr/webpack": "^8.1.0",
75
- "@tanstack/react-query": "^5.101.0",
75
+ "@tanstack/react-query": "^5.101.1",
76
76
  "@types/node": "^24.12.3",
77
- "@types/react": "^19.2.16",
77
+ "@types/react": "^19.2.17",
78
78
  "@types/react-dom": "^19.2.3",
79
79
  "@uniai-fe/eslint-config": "workspace:*",
80
80
  "@uniai-fe/next-devkit": "workspace:*",
@@ -88,11 +88,11 @@
88
88
  "@uniai-fe/util-next": "workspace:*",
89
89
  "@uniai-fe/util-rtc": "workspace:*",
90
90
  "eslint": "^9.39.2",
91
- "jotai": "^2.20.0",
91
+ "jotai": "^2.20.1",
92
92
  "next": "^15.5.18",
93
- "prettier": "^3.8.3",
94
- "react-hook-form": "^7.77.0",
95
- "sass": "^1.100.0",
93
+ "prettier": "^3.8.4",
94
+ "react-hook-form": "^7.80.0",
95
+ "sass": "^1.101.0",
96
96
  "typescript": "5.9.3"
97
97
  }
98
98
  }
@@ -1,22 +1,129 @@
1
1
  "use client";
2
2
 
3
3
  import { useQuery, type UseQueryResult } from "@tanstack/react-query";
4
+ import { getApiError } from "@uniai-fe/util-api";
4
5
  import type {
5
6
  API_Req_CctvRtcToken,
6
7
  API_Res_CctvCompany,
7
8
  API_Res_CctvRtcToken,
8
9
  } from "../types";
9
10
 
11
+ /**
12
+ * CCTV; RTC token fallback max age
13
+ * @desc
14
+ * JWT exp를 읽을 수 없는 token은 React Query dataUpdatedAt 기준으로 이 시간까지만 신규 WHEP 연결에 재사용한다.
15
+ */
10
16
  export const CCTV_RTC_TOKEN_FALLBACK_MAX_AGE_MS = 5 * 60 * 1000;
17
+
18
+ /**
19
+ * CCTV; RTC token query cache retention
20
+ * @desc
21
+ * focus/reconnect 자동 refetch는 막되, 오래된 token이 무기한 신규 연결에 쓰이지 않게 fallback max age와 동일하게 제한한다.
22
+ */
11
23
  export const CCTV_RTC_TOKEN_GC_TIME_MS = CCTV_RTC_TOKEN_FALLBACK_MAX_AGE_MS;
12
24
 
25
+ /**
26
+ * CCTV; RTC token request error
27
+ * @property {number} [statusCode] service token route HTTP status
28
+ * @desc
29
+ * fetch 성공 여부와 API payload 실패 여부를 React Query error 상태로 통일하기 위한 내부 error다.
30
+ */
31
+ class CctvRtcTokenRequestError extends Error {
32
+ readonly statusCode?: number;
33
+
34
+ constructor(message: string, statusCode?: number) {
35
+ super(message);
36
+ this.name = "CctvRtcTokenRequestError";
37
+ this.statusCode = statusCode;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * CCTV; RTC token direct response guard
43
+ * @param {unknown} payload 검사할 token response payload
44
+ * @returns {payload is API_Res_CctvRtcToken} direct `{ token }` 응답 여부
45
+ */
46
+ const isCctvRtcTokenResponse = (
47
+ payload: unknown,
48
+ ): payload is API_Res_CctvRtcToken => {
49
+ if (!payload || typeof payload !== "object") {
50
+ return false;
51
+ }
52
+
53
+ const nextPayload = payload as Partial<API_Res_CctvRtcToken>;
54
+
55
+ return (
56
+ typeof nextPayload.token === "string" && nextPayload.token.trim() !== ""
57
+ );
58
+ };
59
+
60
+ /**
61
+ * CCTV; RTC token payload normalizer
62
+ * @param {unknown} payload service token route 응답 payload
63
+ * @returns {API_Res_CctvRtcToken | null} 사용 가능한 token 응답
64
+ * @desc
65
+ * service route가 direct `{ token }` 또는 API base `{ data: { token } }`를 반환하는 경우를 모두 소비한다.
66
+ */
67
+ const getCctvRtcTokenResponse = (
68
+ payload: unknown,
69
+ ): API_Res_CctvRtcToken | null => {
70
+ if (isCctvRtcTokenResponse(payload)) {
71
+ return payload;
72
+ }
73
+
74
+ if (!payload || typeof payload !== "object") {
75
+ return null;
76
+ }
77
+
78
+ const nextPayload = payload as { data?: unknown };
79
+
80
+ return isCctvRtcTokenResponse(nextPayload.data) ? nextPayload.data : null;
81
+ };
82
+
83
+ /**
84
+ * CCTV; RTC token query retry policy
85
+ * @param {number} failureCount 현재 실패 횟수
86
+ * @param {Error} error queryFn에서 throw된 error
87
+ * @returns {boolean} 재시도 여부
88
+ * @desc
89
+ * 인증/권한/config/응답 계약 오류는 재시도하지 않고, 5xx 또는 network error만 1회 재시도한다.
90
+ */
91
+ const shouldRetryCctvRtcTokenQuery = (
92
+ failureCount: number,
93
+ error: Error,
94
+ ): boolean => {
95
+ if (
96
+ error instanceof CctvRtcTokenRequestError &&
97
+ (error.statusCode === undefined || error.statusCode < 500)
98
+ ) {
99
+ return false;
100
+ }
101
+
102
+ return failureCount < 1;
103
+ };
104
+
105
+ /**
106
+ * CCTV; client company-list fetcher
107
+ * @param {object} params 요청 파라미터
108
+ * @param {string} [params.url] service에서 주입한 company-list API 경로
109
+ * @returns {Promise<API_Res_CctvCompany>} CCTV company-list 응답
110
+ */
13
111
  export const getClientCctvCompanyList = async ({
14
112
  url,
15
113
  }: {
16
114
  url?: string;
17
115
  }): Promise<API_Res_CctvCompany> =>
116
+ // Provider에서 listUrl을 주입하지 않으면 UDS 기본 Next route를 사용한다.
18
117
  await (await fetch(url ?? "/api/cctv/company-list")).json();
19
118
 
119
+ /**
120
+ * CCTV; company-list query hook
121
+ * @hook
122
+ * @param {object} params query 파라미터
123
+ * @param {string} [params.url] service에서 주입한 company-list API 경로
124
+ * @param {readonly unknown[]} [params.queryKeyDeps] service별 목록 cache 분리용 key dependency
125
+ * @returns {UseQueryResult<API_Res_CctvCompany>} company-list query 결과
126
+ */
20
127
  export const useQueryCctvCompanyList = ({
21
128
  url,
22
129
  queryKeyDeps = [],
@@ -25,40 +132,95 @@ export const useQueryCctvCompanyList = ({
25
132
  queryKeyDeps?: readonly unknown[];
26
133
  }): UseQueryResult<API_Res_CctvCompany> =>
27
134
  useQuery({
135
+ // username/site 등 service-local 조건이 바뀌면 queryKeyDeps로 cache를 분리한다.
28
136
  queryKey: ["cctv_company_list", url, ...queryKeyDeps],
29
137
  queryFn: () => getClientCctvCompanyList({ url }),
30
138
  });
31
139
 
140
+ /**
141
+ * CCTV; RTC token POST fetcher
142
+ * @param {API_Req_CctvRtcToken & { url?: string }} params token 요청 파라미터
143
+ * @param {string} params.company_id 업체 id코드
144
+ * @param {string} params.cam_id 카메라 id코드
145
+ * @param {string} params.username CCTV token 권한 확인용 계정명
146
+ * @param {string} [params.url] service에서 주입한 token API 경로
147
+ * @returns {Promise<API_Res_CctvRtcToken>} RTC token 응답
148
+ * @desc
149
+ * - UDS는 기본 token route로 fallback하지 않고 service가 주입한 `url`만 호출한다.
150
+ * - body의 `company_id`, `cam_id`, `username`은 token route가 권한 검증에 사용할 client-visible 식별자다.
151
+ * - service route는 이 값을 신뢰하지 말고 authenticated session 기준으로 접근 권한을 재검증해야 한다.
152
+ */
32
153
  export const postCctvRtcToken = async ({
33
154
  company_id,
34
155
  cam_id,
35
156
  username,
36
157
  url,
37
- }: API_Req_CctvRtcToken & { url?: string }): Promise<API_Res_CctvRtcToken> =>
38
- await (
39
- await fetch(url ?? "/api/cctv/token", {
40
- method: "POST",
41
- headers: {
42
- "Content-Type": "application/json",
43
- },
44
- body: JSON.stringify({ company_id, cam_id, username }),
45
- })
46
- ).json();
158
+ }: API_Req_CctvRtcToken & { url?: string }): Promise<API_Res_CctvRtcToken> => {
159
+ if (!url) {
160
+ throw new CctvRtcTokenRequestError("CCTV token URL is required.");
161
+ }
162
+
163
+ const response = await fetch(url, {
164
+ method: "POST",
165
+ headers: {
166
+ "Content-Type": "application/json",
167
+ },
168
+ body: JSON.stringify({ company_id, cam_id, username }),
169
+ });
170
+ const payload: unknown = await response.json().catch(() => undefined);
171
+ const apiError = getApiError(response, payload);
172
+
173
+ if (apiError) {
174
+ throw new CctvRtcTokenRequestError(
175
+ apiError.message ?? "CCTV token request failed.",
176
+ apiError.code,
177
+ );
178
+ }
179
+
180
+ const tokenResponse = getCctvRtcTokenResponse(payload);
181
+
182
+ if (!tokenResponse) {
183
+ throw new CctvRtcTokenRequestError(
184
+ "CCTV token response is missing token.",
185
+ response.status,
186
+ );
187
+ }
188
+
189
+ return tokenResponse;
190
+ };
47
191
 
192
+ /**
193
+ * CCTV; RTC token query hook
194
+ * @hook
195
+ * @param {API_Req_CctvRtcToken & { enabled?: boolean; url?: string }} params token query 파라미터
196
+ * @param {string} params.company_id 업체 id코드
197
+ * @param {string} params.cam_id 카메라 id코드
198
+ * @param {boolean} [params.enabled] scheduler grant 이후 token query를 열기 위한 외부 gate
199
+ * @param {string} params.username CCTV token 권한 확인용 계정명
200
+ * @param {string} [params.url] service에서 주입한 token API 경로
201
+ * @returns {UseQueryResult<API_Res_CctvRtcToken>} RTC token query 결과
202
+ */
48
203
  export const useQueryCctvRtcToken = ({
49
204
  company_id,
50
205
  cam_id,
206
+ enabled = true,
51
207
  username,
52
208
  url,
53
209
  }: API_Req_CctvRtcToken & {
210
+ enabled?: boolean;
54
211
  url?: string;
55
212
  }): UseQueryResult<API_Res_CctvRtcToken> =>
56
213
  useQuery({
214
+ // token은 username/company/cam/API route 조합별로 분리한다.
57
215
  queryKey: ["cctv_rtc_token", username, company_id, cam_id, url],
58
216
  queryFn: () => postCctvRtcToken({ company_id, cam_id, username, url }),
59
- enabled: Boolean(username && company_id && cam_id),
217
+ // scheduler grant 전에는 token burst가 생기지 않도록 enabled를 false로 유지한다.
218
+ enabled: enabled && Boolean(username && company_id && cam_id),
219
+ // exp decode가 불가한 token은 fallback age를 넘기면 신규 WHEP 연결에 쓰지 않는다.
60
220
  staleTime: CCTV_RTC_TOKEN_FALLBACK_MAX_AGE_MS,
61
221
  gcTime: CCTV_RTC_TOKEN_GC_TIME_MS,
222
+ retry: shouldRetryCctvRtcTokenQuery,
223
+ // focus/reconnect/mount가 곧바로 다수 WHEP 재협상으로 이어지지 않도록 자동 refetch를 막는다.
62
224
  refetchOnMount: false,
63
225
  refetchOnReconnect: false,
64
226
  refetchOnWindowFocus: false,
@@ -14,6 +14,10 @@ import {
14
14
  createCctvRtcStreamRegistry,
15
15
  type CctvRtcStreamRegistry,
16
16
  } from "../hooks/streamRegistry";
17
+ import {
18
+ createCctvRtcStreamScheduler,
19
+ type CctvRtcStreamScheduler,
20
+ } from "../hooks/streamScheduler";
17
21
 
18
22
  /**
19
23
  * CCTV; API 경로 컨텍스트
@@ -28,6 +32,9 @@ const ApiUrlContext = createContext<CctvApiUrlContext>({
28
32
  const RtcStreamRegistryContext = createContext<CctvRtcStreamRegistry | null>(
29
33
  null,
30
34
  );
35
+ const RtcStreamSchedulerContext = createContext<CctvRtcStreamScheduler | null>(
36
+ null,
37
+ );
31
38
 
32
39
  /**
33
40
  * CCTV; API 경로 컨텍스트
@@ -40,6 +47,13 @@ export function useCctvApiUrl(): CctvApiUrlContext {
40
47
  return useContext(ApiUrlContext);
41
48
  }
42
49
 
50
+ /**
51
+ * CCTV; RTC stream registry context hook
52
+ * @hook
53
+ * @returns {CctvRtcStreamRegistry} Provider instance 단위 stream registry
54
+ * @desc
55
+ * registry는 WHEP PeerConnection/MediaStream을 Provider lifecycle 안에서 유지하고 list/viewer video reattach를 담당한다.
56
+ */
43
57
  export function useCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
44
58
  const registry = useContext(RtcStreamRegistryContext);
45
59
  if (!registry) {
@@ -50,6 +64,23 @@ export function useCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
50
64
  return registry;
51
65
  }
52
66
 
67
+ /**
68
+ * CCTV; RTC stream scheduler context hook
69
+ * @hook
70
+ * @returns {CctvRtcStreamScheduler} Provider instance 단위 stream scheduler
71
+ * @desc
72
+ * scheduler는 item UI 렌더와 token/WHEP 시작 시점을 분리해 초기 요청 burst를 batch 단위로 줄인다.
73
+ */
74
+ export function useCctvRtcStreamScheduler(): CctvRtcStreamScheduler {
75
+ const scheduler = useContext(RtcStreamSchedulerContext);
76
+ if (!scheduler) {
77
+ throw new Error(
78
+ "CCTV.Provider 내부에서 useCctvRtcStreamScheduler를 사용해야 합니다.",
79
+ );
80
+ }
81
+ return scheduler;
82
+ }
83
+
53
84
  /**
54
85
  * CCTV; Provider props
55
86
  * @component
@@ -60,6 +91,7 @@ export function useCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
60
91
  * @property {string} [username] CCTV 조회시 권한 확인을 위한 계정명
61
92
  * @property {string} [whepUsername] WHEP endpoint username 덮어쓰기 값
62
93
  * @property {ContextExtension} [defaultValues] 서비스 확장 context 기본값
94
+ * @property {CctvRtcStreamOptions} [streamOptions] stream lazy loading 옵션
63
95
  * @property {React.ReactNode} children
64
96
  */
65
97
  export default function CCTVProvider<ContextExtension extends object = object>({
@@ -70,15 +102,39 @@ export default function CCTVProvider<ContextExtension extends object = object>({
70
102
  listUrl,
71
103
  tokenUrl,
72
104
  defaultValues,
105
+ streamOptions,
73
106
  children,
74
107
  }: CctvProviderProps<ContextExtension>) {
108
+ // streamOptions object identity가 매 렌더 바뀌어도 primitive option 값 기준으로만 scheduler를 교체한다.
109
+ const streamBatchSize = streamOptions?.batchSize;
110
+ const streamBatchIntervalMs = streamOptions?.batchIntervalMs;
111
+ const hasStreamOptions =
112
+ streamBatchSize !== undefined || streamBatchIntervalMs !== undefined;
113
+
114
+ // registry는 Provider lifetime 동안 유지해 list/viewer 전환 시 기존 MediaStream을 재사용한다.
75
115
  const streamRegistry = useMemo(() => createCctvRtcStreamRegistry(), []);
76
116
 
117
+ // scheduler는 Provider option이 바뀌는 경우에만 새 batch 정책으로 다시 만든다.
118
+ const streamScheduler = useMemo(
119
+ () =>
120
+ createCctvRtcStreamScheduler(
121
+ hasStreamOptions
122
+ ? {
123
+ batchIntervalMs: streamBatchIntervalMs,
124
+ batchSize: streamBatchSize,
125
+ }
126
+ : undefined,
127
+ ),
128
+ [hasStreamOptions, streamBatchIntervalMs, streamBatchSize],
129
+ );
130
+
77
131
  useEffect(() => {
78
132
  return () => {
133
+ // Provider unmount가 CCTV stream lifecycle의 최종 정리 지점이다.
79
134
  streamRegistry.closeAll();
135
+ streamScheduler.clear();
80
136
  };
81
- }, [streamRegistry]);
137
+ }, [streamRegistry, streamScheduler]);
82
138
 
83
139
  const mergedDefaultValues = {
84
140
  // 변경 설명: service 확장 context 기본값을 core CCTV form context와 한 RHF scope에서 병합한다.
@@ -93,13 +149,15 @@ export default function CCTVProvider<ContextExtension extends object = object>({
93
149
  return (
94
150
  <ApiUrlContext.Provider value={{ listUrl, tokenUrl }}>
95
151
  <RtcStreamRegistryContext.Provider value={streamRegistry}>
96
- <Form.Provider<CctvContext<ContextExtension>>
97
- options={{
98
- defaultValues: mergedDefaultValues,
99
- }}
100
- >
101
- {children}
102
- </Form.Provider>
152
+ <RtcStreamSchedulerContext.Provider value={streamScheduler}>
153
+ <Form.Provider<CctvContext<ContextExtension>>
154
+ options={{
155
+ defaultValues: mergedDefaultValues,
156
+ }}
157
+ >
158
+ {children}
159
+ </Form.Provider>
160
+ </RtcStreamSchedulerContext.Provider>
103
161
  </RtcStreamRegistryContext.Provider>
104
162
  </ApiUrlContext.Provider>
105
163
  );
@@ -25,8 +25,10 @@ export default function CCTVCamListItem({
25
25
  renderOverlay,
26
26
  ...cam
27
27
  }: CctvCamListItemProps) {
28
+ // item은 즉시 렌더하지만 token/WHEP 시작은 hook 내부 scheduler grant 이후로 지연된다.
28
29
  const { videoRef, ...rtcCtx } = useCctvRtcStream({ cam });
29
30
 
31
+ // live/error/message는 video-state util 하나를 기준으로 계산해 CamList와 Viewer 표시 규칙을 맞춘다.
30
32
  const isLive = useMemo(() => getIsLive({ cam, ...rtcCtx }), [cam, rtcCtx]);
31
33
  const isError = useMemo(() => getIsError({ cam, ...rtcCtx }), [cam, rtcCtx]);
32
34
  const overlayMessage = useMemo(
@@ -44,9 +46,11 @@ export default function CCTVCamListItem({
44
46
  )}
45
47
  >
46
48
  <CCTVVideoTemplate
49
+ // hook이 반환한 ref에 registry MediaStream이 attach된다.
47
50
  ref={videoRef}
48
51
  cam={cam}
49
52
  className="cctv-cam-list-video-container"
53
+ // list card에서는 live/share 상태를 header에 노출하고 제목은 footer로 내려 보낸다.
50
54
  headerOptions={{
51
55
  activeLiveState: true,
52
56
  activeTitle: false,
@@ -58,9 +62,11 @@ export default function CCTVCamListItem({
58
62
  // 변경 설명: list item custom overlay는 template seam 하나만 따라간다.
59
63
  renderOverlay={renderOverlay}
60
64
  {...{
65
+ // Video.Template이 overlay/body/footer를 한 번에 판단할 수 있도록 stream 상태를 함께 넘긴다.
61
66
  isError,
62
67
  overlayMessage,
63
68
  isLive,
69
+ streamStage: rtcCtx.streamStage,
64
70
  canReconnect: rtcCtx.canReconnect,
65
71
  reconnectStream: rtcCtx.reconnectStream,
66
72
  }}
@@ -11,14 +11,29 @@ import {
11
11
  getIsError,
12
12
  } from "../../../utils/video-state";
13
13
 
14
+ /**
15
+ * CCTV; pagination list item
16
+ * @component
17
+ * @param {object} props
18
+ * @param {string} [props.className] pagination thumbnail item 클래스
19
+ * @param {string} props.cam_id 카메라 id코드
20
+ * @param {string} props.cam_name 카메라명
21
+ * @param {string} props.company_id 업체 id코드
22
+ * @param {boolean} props.cam_online 카메라 온라인 여부
23
+ * @param {string} props.cam_rtc WebRTC endpoint base URL
24
+ * @desc
25
+ * viewer 하단 thumbnail에서도 동일한 `useCctvRtcStream` 경로를 사용해 list/viewer 전환 시 registry reattach 계약을 유지한다.
26
+ */
14
27
  export default function CCTVPaginationListItem({
15
28
  className,
16
29
  ...cam
17
30
  }: {
18
31
  className?: string;
19
32
  } & CctvCompanyCameraData) {
33
+ // thumbnail은 즉시 렌더하고, 실제 token/WHEP 시작은 Provider scheduler가 batch 단위로 grant한다.
20
34
  const { videoRef, ...rtcCtx } = useCctvRtcStream({ cam });
21
35
 
36
+ // CamList item과 동일한 util을 사용해 overlay 상태 판정 drift를 막는다.
22
37
  const isLive = useMemo(() => getIsLive({ cam, ...rtcCtx }), [cam, rtcCtx]);
23
38
  const isError = useMemo(() => getIsError({ cam, ...rtcCtx }), [cam, rtcCtx]);
24
39
  const overlayMessage = useMemo(
@@ -26,6 +41,7 @@ export default function CCTVPaginationListItem({
26
41
  [cam, rtcCtx],
27
42
  );
28
43
 
44
+ // cam.onSelect는 render data에 주입된 선택 handler라 있을 때만 호출한다.
29
45
  const handleSelect = useCallback(() => {
30
46
  if (typeof cam.onSelect === "function") cam.onSelect();
31
47
  }, [cam]);
@@ -47,9 +63,11 @@ export default function CCTVPaginationListItem({
47
63
  aria-label={`${cam.cam_name} 선택`}
48
64
  >
49
65
  <CCTVVideoTemplate
66
+ // registry가 같은 streamKey의 MediaStream을 thumbnail video에 attach한다.
50
67
  ref={videoRef}
51
68
  cam={cam}
52
69
  className="cctv-pagination-list-video-container"
70
+ // thumbnail에서는 header live/share 상태만 보이고 footer action은 최소화한다.
53
71
  headerOptions={{
54
72
  activeLiveState: true,
55
73
  activeTitle: false,
@@ -59,9 +77,11 @@ export default function CCTVPaginationListItem({
59
77
  }}
60
78
  footerOptions={{ cam }}
61
79
  {...{
80
+ // Video.Template overlay가 queued/token-loading/connecting/error 상태를 통합 표시한다.
62
81
  isError,
63
82
  overlayMessage,
64
83
  isLive,
84
+ streamStage: rtcCtx.streamStage,
65
85
  canReconnect: rtcCtx.canReconnect,
66
86
  reconnectStream: rtcCtx.reconnectStream,
67
87
  }}
@@ -17,6 +17,7 @@ import type { CctvVideoTemplateProps } from "../../types/props";
17
17
  * @property {boolean} [isError]
18
18
  * @property {React.ReactNode} [overlayMessage]
19
19
  * @property {boolean} [isLive]
20
+ * @property {CctvRtcStreamStage} [streamStage]
20
21
  * @property {CctvCompanyCameraData} [cam]
21
22
  * @property {boolean} [canReconnect]
22
23
  * @property {CctvRtcReconnectTrigger} [reconnectStream]
@@ -33,6 +34,7 @@ const CCTVVideoTemplate = forwardRef<HTMLVideoElement, CctvVideoTemplateProps>(
33
34
  isError,
34
35
  isLive,
35
36
  overlayMessage,
37
+ streamStage,
36
38
  canReconnect,
37
39
  reconnectStream,
38
40
  renderOverlay,
@@ -60,6 +62,7 @@ const CCTVVideoTemplate = forwardRef<HTMLVideoElement, CctvVideoTemplateProps>(
60
62
  isError,
61
63
  isLive,
62
64
  overlayMessage,
65
+ streamStage,
63
66
  canReconnect,
64
67
  reconnectStream,
65
68
  })
@@ -61,6 +61,7 @@ export default function CCTVViewerDesktopVideo({
61
61
  isError,
62
62
  overlayMessage,
63
63
  isLive,
64
+ streamStage: rtcCtx.streamStage,
64
65
  canReconnect: rtcCtx.canReconnect,
65
66
  reconnectStream: rtcCtx.reconnectStream,
66
67
  }}