@uniai-fe/uds-templates 0.5.7 → 0.5.9

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.7",
3
+ "version": "0.5.9",
4
4
  "description": "UNIAI Design System; UI Templates Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -51,4 +51,10 @@ export const useQueryCctvRtcToken = ({
51
51
  useQuery({
52
52
  queryKey: ["cctv_rtc_token", username, company_id, cam_id, url],
53
53
  queryFn: () => postCctvRtcToken({ company_id, cam_id, username, url }),
54
+ enabled: Boolean(username && company_id && cam_id),
55
+ staleTime: Infinity,
56
+ gcTime: Infinity,
57
+ refetchOnMount: false,
58
+ refetchOnReconnect: false,
59
+ refetchOnWindowFocus: false,
54
60
  });
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { createContext, useContext } from "react";
3
+ import { createContext, useContext, useEffect, useMemo } from "react";
4
4
  import { Form } from "@uniai-fe/uds-primitives";
5
5
  import type { DefaultValues } from "react-hook-form";
6
6
 
@@ -10,6 +10,10 @@ import type {
10
10
  CctvProviderProps,
11
11
  } from "../types";
12
12
  import { CCTV_CONTEXT_DEFAULT_VALUES } from "../data";
13
+ import {
14
+ createCctvRtcStreamRegistry,
15
+ type CctvRtcStreamRegistry,
16
+ } from "../hooks/streamRegistry";
13
17
 
14
18
  /**
15
19
  * CCTV; API 경로 컨텍스트
@@ -21,6 +25,10 @@ const ApiUrlContext = createContext<CctvApiUrlContext>({
21
25
  tokenUrl: undefined,
22
26
  });
23
27
 
28
+ const RtcStreamRegistryContext = createContext<CctvRtcStreamRegistry | null>(
29
+ null,
30
+ );
31
+
24
32
  /**
25
33
  * CCTV; API 경로 컨텍스트
26
34
  * @return {CctvApiUrlContext}
@@ -32,6 +40,16 @@ export function useCctvApiUrl(): CctvApiUrlContext {
32
40
  return useContext(ApiUrlContext);
33
41
  }
34
42
 
43
+ export function useCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
44
+ const registry = useContext(RtcStreamRegistryContext);
45
+ if (!registry) {
46
+ throw new Error(
47
+ "CCTV.Provider 내부에서 useCctvRtcStreamRegistry를 사용해야 합니다.",
48
+ );
49
+ }
50
+ return registry;
51
+ }
52
+
35
53
  /**
36
54
  * CCTV; Provider props
37
55
  * @component
@@ -52,6 +70,14 @@ export default function CCTVProvider<ContextExtension extends object = object>({
52
70
  defaultValues,
53
71
  children,
54
72
  }: CctvProviderProps<ContextExtension>) {
73
+ const streamRegistry = useMemo(() => createCctvRtcStreamRegistry(), []);
74
+
75
+ useEffect(() => {
76
+ return () => {
77
+ streamRegistry.closeAll();
78
+ };
79
+ }, [streamRegistry]);
80
+
55
81
  const mergedDefaultValues = {
56
82
  // 변경 설명: service 확장 context 기본값을 core CCTV form context와 한 RHF scope에서 병합한다.
57
83
  ...defaultValues,
@@ -63,13 +89,15 @@ export default function CCTVProvider<ContextExtension extends object = object>({
63
89
 
64
90
  return (
65
91
  <ApiUrlContext.Provider value={{ listUrl, tokenUrl }}>
66
- <Form.Provider<CctvContext<ContextExtension>>
67
- options={{
68
- defaultValues: mergedDefaultValues,
69
- }}
70
- >
71
- {children}
72
- </Form.Provider>
92
+ <RtcStreamRegistryContext.Provider value={streamRegistry}>
93
+ <Form.Provider<CctvContext<ContextExtension>>
94
+ options={{
95
+ defaultValues: mergedDefaultValues,
96
+ }}
97
+ >
98
+ {children}
99
+ </Form.Provider>
100
+ </RtcStreamRegistryContext.Provider>
73
101
  </ApiUrlContext.Provider>
74
102
  );
75
103
  }
@@ -0,0 +1,195 @@
1
+ "use client";
2
+
3
+ import { startWhepStream, type WhepStreamHandle } from "@uniai-fe/util-rtc";
4
+
5
+ interface CctvRtcStreamSnapshot {
6
+ connectionState: RTCPeerConnectionState;
7
+ isStreaming: boolean;
8
+ stream: MediaStream | null;
9
+ streamError: string | null;
10
+ }
11
+
12
+ interface CctvRtcStreamEntry extends CctvRtcStreamSnapshot {
13
+ attachedVideos: Set<HTMLVideoElement>;
14
+ controller: AbortController | null;
15
+ handle: WhepStreamHandle | null;
16
+ identityKey: string;
17
+ listeners: Set<(snapshot: CctvRtcStreamSnapshot) => void>;
18
+ streamKey: string;
19
+ }
20
+
21
+ interface CctvRtcStreamStartParams {
22
+ endpoint: string;
23
+ identityKey: string;
24
+ streamKey: string;
25
+ token: string;
26
+ video: HTMLVideoElement;
27
+ }
28
+
29
+ const getEntrySnapshot = (
30
+ entry?: CctvRtcStreamEntry,
31
+ ): CctvRtcStreamSnapshot => ({
32
+ connectionState: entry?.connectionState ?? "new",
33
+ isStreaming: entry?.isStreaming ?? false,
34
+ stream: entry?.stream ?? null,
35
+ streamError: entry?.streamError ?? null,
36
+ });
37
+
38
+ const attachVideo = (video: HTMLVideoElement, stream: MediaStream | null) => {
39
+ video.playsInline = true;
40
+ video.autoplay = true;
41
+ video.srcObject = stream;
42
+ };
43
+
44
+ const notifyEntry = (entry: CctvRtcStreamEntry) => {
45
+ const snapshot = getEntrySnapshot(entry);
46
+ entry.listeners.forEach(listener => listener(snapshot));
47
+ };
48
+
49
+ const closeEntry = (entry: CctvRtcStreamEntry) => {
50
+ entry.controller?.abort();
51
+ entry.controller = null;
52
+ entry.handle?.close();
53
+ entry.handle = null;
54
+ entry.connectionState = "closed";
55
+ entry.isStreaming = false;
56
+
57
+ entry.attachedVideos.forEach(video => {
58
+ if (!entry.stream || video.srcObject === entry.stream) {
59
+ video.srcObject = null;
60
+ }
61
+ });
62
+
63
+ notifyEntry(entry);
64
+ entry.attachedVideos.clear();
65
+ entry.listeners.clear();
66
+ };
67
+
68
+ export interface CctvRtcStreamRegistry {
69
+ attach: (streamKey: string, video: HTMLVideoElement) => () => void;
70
+ closeAll: () => void;
71
+ closeByIdentity: (identityKey: string, exceptStreamKey?: string) => void;
72
+ getSnapshot: (streamKey: string) => CctvRtcStreamSnapshot;
73
+ start: (params: CctvRtcStreamStartParams) => void;
74
+ subscribe: (
75
+ streamKey: string,
76
+ listener: (snapshot: CctvRtcStreamSnapshot) => void,
77
+ ) => () => void;
78
+ }
79
+
80
+ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
81
+ const entries = new Map<string, CctvRtcStreamEntry>();
82
+
83
+ const deleteEntry = (entry: CctvRtcStreamEntry) => {
84
+ closeEntry(entry);
85
+ entries.delete(entry.streamKey);
86
+ };
87
+
88
+ return {
89
+ attach(streamKey, video) {
90
+ const entry = entries.get(streamKey);
91
+ if (!entry) {
92
+ attachVideo(video, null);
93
+ return () => {
94
+ if (video.srcObject) video.srcObject = null;
95
+ };
96
+ }
97
+
98
+ entry.attachedVideos.add(video);
99
+ attachVideo(video, entry.stream);
100
+
101
+ return () => {
102
+ entry.attachedVideos.delete(video);
103
+ if (!entry.stream || video.srcObject === entry.stream) {
104
+ video.srcObject = null;
105
+ }
106
+ };
107
+ },
108
+ closeAll() {
109
+ entries.forEach(entry => closeEntry(entry));
110
+ entries.clear();
111
+ },
112
+ closeByIdentity(identityKey, exceptStreamKey) {
113
+ entries.forEach(entry => {
114
+ if (
115
+ entry.identityKey === identityKey &&
116
+ entry.streamKey !== exceptStreamKey
117
+ ) {
118
+ deleteEntry(entry);
119
+ }
120
+ });
121
+ },
122
+ getSnapshot(streamKey) {
123
+ return getEntrySnapshot(entries.get(streamKey));
124
+ },
125
+ start({ endpoint, identityKey, streamKey, token, video }) {
126
+ const existingEntry = entries.get(streamKey);
127
+ if (existingEntry) return;
128
+
129
+ const controller = new AbortController();
130
+ const entry: CctvRtcStreamEntry = {
131
+ attachedVideos: new Set([video]),
132
+ connectionState: "connecting",
133
+ controller,
134
+ handle: null,
135
+ identityKey,
136
+ isStreaming: true,
137
+ listeners: new Set(),
138
+ stream: null,
139
+ streamError: null,
140
+ streamKey,
141
+ };
142
+
143
+ entries.set(streamKey, entry);
144
+ attachVideo(video, null);
145
+
146
+ startWhepStream({
147
+ endpoint,
148
+ token,
149
+ video,
150
+ signal: controller.signal,
151
+ onConnectionStateChange: state => {
152
+ entry.connectionState = state;
153
+ notifyEntry(entry);
154
+ },
155
+ onTrack: event => {
156
+ entry.stream = event.streams[0] ?? null;
157
+ entry.attachedVideos.forEach(attachedVideo =>
158
+ attachVideo(attachedVideo, entry.stream),
159
+ );
160
+ notifyEntry(entry);
161
+ },
162
+ })
163
+ .then(handle => {
164
+ entry.handle = handle;
165
+ entry.isStreaming = false;
166
+ notifyEntry(entry);
167
+ })
168
+ .catch(error => {
169
+ entry.controller = null;
170
+ entry.handle = null;
171
+ entry.isStreaming = false;
172
+ entry.stream = null;
173
+ entry.streamError =
174
+ error instanceof Error
175
+ ? error.message
176
+ : "스트림 연결에 실패했습니다.";
177
+ entry.attachedVideos.forEach(attachedVideo =>
178
+ attachVideo(attachedVideo, null),
179
+ );
180
+ notifyEntry(entry);
181
+ });
182
+ },
183
+ subscribe(streamKey, listener) {
184
+ const entry = entries.get(streamKey);
185
+ if (!entry) return () => undefined;
186
+
187
+ entry.listeners.add(listener);
188
+ listener(getEntrySnapshot(entry));
189
+
190
+ return () => {
191
+ entry.listeners.delete(listener);
192
+ };
193
+ },
194
+ };
195
+ }
@@ -1,14 +1,15 @@
1
1
  "use client";
2
2
 
3
3
  // React 훅들을 활용해 WebRTC 스트림 라이프사이클을 제어한다.
4
- import { useEffect, useMemo, useRef, useState } from "react";
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
5
  import { useSetAtom } from "jotai";
6
- // util-rtc 모듈에서 제공하는 WHEP(WebRTC-HTTP Egress Protocol) 헬퍼와 핸들 타입.
7
- import { startWhepStream, type WhepStreamHandle } from "@uniai-fe/util-rtc";
8
6
 
9
7
  // 토큰 발급 쿼리와 API URL 컨텍스트 훅, 타입, react-hook-form 유틸.
10
8
  import { useQueryCctvRtcToken } from "../apis/client";
11
- import { useCctvApiUrl } from "../components/Provider";
9
+ import {
10
+ useCctvApiUrl,
11
+ useCctvRtcStreamRegistry,
12
+ } from "../components/Provider";
12
13
  import type { UseCctvRtcStreamParams, UseCctvRtcStreamReturn } from "../types";
13
14
  import { cctvRtcLiveRegistryAtom } from "../jotai/rtc";
14
15
  import { getIsLive } from "../utils/video-state";
@@ -39,6 +40,7 @@ export function useCctvRtcStream({
39
40
  }: UseCctvRtcStreamParams): UseCctvRtcStreamReturn {
40
41
  // Provider를 통해 주입된 기본 토큰 발급 URL을 확보한다.
41
42
  const { tokenUrl: contextTokenUrl } = useCctvApiUrl();
43
+ const streamRegistry = useCctvRtcStreamRegistry();
42
44
  const setLiveRegistry = useSetAtom(cctvRtcLiveRegistryAtom);
43
45
  const instanceKeyRef = useRef<string | null>(null);
44
46
  if (!instanceKeyRef.current) {
@@ -47,6 +49,8 @@ export function useCctvRtcStream({
47
49
 
48
50
  // WebRTC MediaStream을 연결할 video 요소 ref.
49
51
  const videoRef = useRef<HTMLVideoElement | null>(null);
52
+ const activeStreamKeyRef = useRef<string | null>(null);
53
+ const activeStreamIdentityKeyRef = useRef<string | null>(null);
50
54
 
51
55
  // RTCPeerConnectionState를 관찰해 UI에 노출하기 위한 상태값.
52
56
  const [connectionState, setConnectionState] =
@@ -80,71 +84,115 @@ export function useCctvRtcStream({
80
84
  return `${cam.cam_rtc.replace(/\/$/, "")}/whep${query}`;
81
85
  }, [cam?.cam_rtc, username]);
82
86
 
87
+ const streamKey = useMemo(() => {
88
+ if (!cam?.cam_id || !cam.cam_online || !endpoint || !tokenQuery.data?.token)
89
+ return "";
90
+
91
+ return [
92
+ username,
93
+ cam.company_id,
94
+ cam.cam_id,
95
+ endpoint,
96
+ tokenQuery.dataUpdatedAt,
97
+ ].join("|");
98
+ }, [
99
+ cam?.cam_id,
100
+ cam?.cam_online,
101
+ cam?.company_id,
102
+ endpoint,
103
+ tokenQuery.data?.token,
104
+ tokenQuery.dataUpdatedAt,
105
+ username,
106
+ ]);
107
+
108
+ const streamIdentityKey = useMemo(() => {
109
+ if (!cam?.cam_id || !endpoint) return "";
110
+
111
+ return [username, cam.company_id, cam.cam_id, endpoint].join("|");
112
+ }, [cam?.cam_id, cam?.company_id, endpoint, username]);
113
+
83
114
  // 토큰과 endpoint가 준비되면 WebRTC 스트림을 연결한다.
84
115
  useEffect(() => {
85
116
  const currentVideo = videoRef.current;
86
117
 
87
118
  // 토큰/카메라 에러로 전환되면 직전 MediaStream이 화면에 남지 않도록 비운다.
88
119
  if (tokenQuery.isError || !cam?.cam_online) {
120
+ if (streamIdentityKey) {
121
+ streamRegistry.closeByIdentity(streamIdentityKey);
122
+ }
89
123
  if (currentVideo) currentVideo.srcObject = null;
124
+ setConnectionState("new");
125
+ setStreamError(null);
126
+ setStreaming(false);
90
127
  return;
91
128
  }
92
129
 
93
130
  // 필수 값이 없으면 스트림을 시작하지 않는다.
94
- if (!tokenQuery.data?.token || !endpoint || !currentVideo) return;
95
-
96
- let handle: WhepStreamHandle | null = null;
131
+ if (!streamKey || !tokenQuery.data?.token || !endpoint || !currentVideo)
132
+ return;
97
133
 
98
- // fetch / PeerConnection을 취소하기 위한 AbortController.
99
- const controller = new AbortController();
134
+ if (
135
+ activeStreamIdentityKeyRef.current === streamIdentityKey &&
136
+ activeStreamKeyRef.current !== streamKey
137
+ ) {
138
+ streamRegistry.closeByIdentity(streamIdentityKey, streamKey);
139
+ }
100
140
 
101
- // 기존 오류를 초기화하고 스트리밍 상태를 true로 설정한다.
102
- setStreamError(null);
103
- setStreaming(true);
104
- setConnectionState("connecting");
141
+ activeStreamKeyRef.current = streamKey;
142
+ activeStreamIdentityKeyRef.current = streamIdentityKey;
105
143
 
106
- // util-rtc의 startWhepStream으로 SDP 교환과 스트림 연결을 수행한다.
107
- startWhepStream({
144
+ streamRegistry.start({
145
+ streamKey,
146
+ identityKey: streamIdentityKey,
108
147
  endpoint,
109
148
  token: tokenQuery.data.token,
110
149
  video: currentVideo,
111
- signal: controller.signal,
112
- onConnectionStateChange: state => setConnectionState(state),
113
- })
114
- .then(streamHandle => {
115
- // cleanup 시 close를 호출하기 위해 핸들을 저장한다.
116
- handle = streamHandle;
117
- })
118
- .catch(error => {
119
- // 스트림 실패 시에도 이전 프레임이 에러 오버레이 뒤에 남지 않도록 정리한다.
120
- currentVideo.srcObject = null;
121
- // Error 객체 여부에 따라 메시지를 정규화한다.
122
- setStreamError(
123
- error instanceof Error
124
- ? error.message
125
- : "스트림 연결에 실패했습니다.",
126
- );
127
- })
128
- .finally(() => {
129
- // 성공/실패에 관계없이 스트리밍 상태를 false로 되돌린다.
130
- setStreaming(false);
131
- });
150
+ });
151
+
152
+ const detachVideo = streamRegistry.attach(streamKey, currentVideo);
153
+ const unsubscribe = streamRegistry.subscribe(streamKey, snapshot => {
154
+ setConnectionState(snapshot.connectionState);
155
+ setStreamError(snapshot.streamError);
156
+ setStreaming(snapshot.isStreaming);
157
+ if (snapshot.stream && currentVideo.srcObject !== snapshot.stream) {
158
+ currentVideo.srcObject = snapshot.stream;
159
+ }
160
+ });
161
+ const snapshot = streamRegistry.getSnapshot(streamKey);
162
+ setConnectionState(snapshot.connectionState);
163
+ setStreamError(snapshot.streamError);
164
+ setStreaming(snapshot.isStreaming);
165
+
166
+ if (snapshot.stream && currentVideo.srcObject !== snapshot.stream) {
167
+ currentVideo.srcObject = snapshot.stream;
168
+ }
132
169
 
133
- // effect cleanup: AbortSignal 취소 peer 리소스 정리.
170
+ // effect cleanup: current video attach만 해제하고 registry stream은 유지한다.
134
171
  return () => {
135
- controller.abort();
136
- handle?.close();
137
- handle = null;
138
- currentVideo.srcObject = null;
172
+ unsubscribe();
173
+ detachVideo();
139
174
  };
140
175
  }, [
141
176
  endpoint,
177
+ streamIdentityKey,
178
+ streamKey,
179
+ streamRegistry,
142
180
  tokenQuery.data?.token,
143
181
  tokenQuery.isError,
144
- cam?.cam_id,
145
182
  cam?.cam_online,
146
183
  ]);
147
184
 
185
+ const refetchToken = useCallback<typeof tokenQuery.refetch>(
186
+ async options => {
187
+ const result = await tokenQuery.refetch(options);
188
+ if (result.isSuccess && streamIdentityKey) {
189
+ streamRegistry.closeByIdentity(streamIdentityKey);
190
+ }
191
+ return result;
192
+ },
193
+ [streamIdentityKey, streamRegistry, tokenQuery],
194
+ );
195
+
148
196
  const liveState = useMemo(
149
197
  () =>
150
198
  cam
@@ -202,6 +250,6 @@ export function useCctvRtcStream({
202
250
  isStreaming,
203
251
  isTokenLoading,
204
252
  isTokenError,
205
- refetchToken: tokenQuery.refetch,
253
+ refetchToken,
206
254
  };
207
255
  }
@@ -32,6 +32,7 @@ export function ModalRoot({
32
32
  }: ModalRootProps) {
33
33
  const { updateModal, closeModal } = useModal();
34
34
  const surfaceRef = useRef<HTMLDivElement | null>(null);
35
+ const isPointerDownOutsideRef = useRef(false);
35
36
 
36
37
  useEffect(() => {
37
38
  if (modalProps.show === "init") {
@@ -55,6 +56,40 @@ export function ModalRoot({
55
56
  closeModal({ stackKey });
56
57
  }, [closeModal, modalProps.closeOnOutsideClick, stackKey]);
57
58
 
59
+ const isOutsideSurface = useCallback((eventTarget: EventTarget | null) => {
60
+ const surface = surfaceRef.current;
61
+ return (
62
+ !(eventTarget instanceof Node) ||
63
+ !surface ||
64
+ !surface.contains(eventTarget)
65
+ );
66
+ }, []);
67
+
68
+ const handlePointerDown = useCallback(
69
+ (event: React.PointerEvent<HTMLDivElement>) => {
70
+ isPointerDownOutsideRef.current = isOutsideSurface(event.target);
71
+ },
72
+ [isOutsideSurface],
73
+ );
74
+
75
+ const handlePointerUp = useCallback(
76
+ (event: React.PointerEvent<HTMLDivElement>) => {
77
+ const shouldClose =
78
+ isPointerDownOutsideRef.current && isOutsideSurface(event.target);
79
+
80
+ isPointerDownOutsideRef.current = false;
81
+
82
+ if (shouldClose) {
83
+ handleClose();
84
+ }
85
+ },
86
+ [handleClose, isOutsideSurface],
87
+ );
88
+
89
+ const handlePointerCancel = useCallback(() => {
90
+ isPointerDownOutsideRef.current = false;
91
+ }, []);
92
+
58
93
  const stopPropagation = useCallback(
59
94
  (event: React.MouseEvent<HTMLDivElement>) => {
60
95
  event.stopPropagation();
@@ -94,7 +129,9 @@ export function ModalRoot({
94
129
  className={clsx("uds-modal-root", className)}
95
130
  data-state={dataState}
96
131
  style={layerStyle}
97
- onClick={handleClose}
132
+ onPointerDown={handlePointerDown}
133
+ onPointerUp={handlePointerUp}
134
+ onPointerCancel={handlePointerCancel}
98
135
  role="presentation"
99
136
  >
100
137
  <div className="uds-modal-dimmer" />