astra-sdk-web 1.0.0 → 1.1.0
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 +13 -2
- package/src/App.tsx +12 -8
- package/src/components/KycFlow.tsx +41 -0
- package/src/components/index.ts +3 -0
- package/src/contexts/KycContext.tsx +66 -0
- package/src/features/documentUpload/hooks/useDocumentUpload.ts +226 -0
- package/src/features/documentUpload/index.ts +3 -0
- package/src/features/documentUpload/types.ts +16 -0
- package/src/features/faceScan/hooks/useCamera.ts +62 -0
- package/src/features/faceScan/hooks/useFaceScan.ts +249 -0
- package/src/features/faceScan/index.ts +4 -0
- package/src/features/faceScan/types.ts +29 -0
- package/src/index.css +13 -62
- package/src/pages/DocumentUploadModal.tsx +262 -0
- package/src/pages/FaceScanModal.tsx +207 -0
- package/src/pages/MobileRoute.tsx +42 -0
- package/src/pages/QRCodePage.tsx +125 -0
- package/src/sdk/index.ts +5 -30
- package/src/services/faceMeshService.ts +382 -0
- package/src/services/index.ts +5 -0
- package/src/services/kycApiService.ts +194 -0
- package/src/utils/deviceDetection.ts +28 -0
- package/src/App.css +0 -42
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "astra-sdk-web",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Official Astra SDK for JavaScript/TypeScript",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/astra-sdk.cjs.js",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"scripts": {
|
|
29
29
|
"dev": "vite",
|
|
30
30
|
"build": "tsc && vite build",
|
|
31
|
-
"build:lib": "tsc && vite build",
|
|
31
|
+
"build:lib": "tsc && BUILD_LIB=true vite build",
|
|
32
32
|
"lint": "eslint .",
|
|
33
33
|
"preview": "vite preview",
|
|
34
34
|
"type-check": "tsc --noEmit"
|
|
@@ -39,14 +39,25 @@
|
|
|
39
39
|
"@types/react": "^19.2.5",
|
|
40
40
|
"@types/react-dom": "^19.2.3",
|
|
41
41
|
"@vitejs/plugin-react": "^5.1.1",
|
|
42
|
+
"autoprefixer": "^10.4.23",
|
|
42
43
|
"eslint": "^9.39.1",
|
|
43
44
|
"eslint-plugin-react-hooks": "^7.0.1",
|
|
44
45
|
"eslint-plugin-react-refresh": "^0.4.24",
|
|
45
46
|
"globals": "^16.5.0",
|
|
47
|
+
"postcss": "^8.5.6",
|
|
48
|
+
"tailwindcss": "^3.4.19",
|
|
46
49
|
"terser": "^5.44.1",
|
|
47
50
|
"typescript": "~5.9.3",
|
|
48
51
|
"typescript-eslint": "^8.46.4",
|
|
49
52
|
"vite": "^7.2.4",
|
|
50
53
|
"vite-plugin-dts": "^4.3.0"
|
|
54
|
+
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"@mediapipe/camera_utils": "^0.3.1675466862",
|
|
57
|
+
"@mediapipe/drawing_utils": "^0.3.1675466124",
|
|
58
|
+
"@mediapipe/face_detection": "^0.4.1646425229",
|
|
59
|
+
"@mediapipe/face_mesh": "^0.4.1633559619",
|
|
60
|
+
"qrcode.react": "^4.2.0",
|
|
61
|
+
"react-router-dom": "^7.11.0"
|
|
51
62
|
}
|
|
52
63
|
}
|
package/src/App.tsx
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
import './
|
|
1
|
+
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
|
2
|
+
import QRCodePage from './pages/QRCodePage';
|
|
3
|
+
import MobileRoute from './pages/MobileRoute';
|
|
3
4
|
|
|
4
5
|
function App() {
|
|
5
|
-
|
|
6
6
|
return (
|
|
7
|
-
|
|
8
|
-
<
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
<BrowserRouter>
|
|
8
|
+
<Routes>
|
|
9
|
+
<Route path="/" element={<Navigate to="/qr" replace />} />
|
|
10
|
+
<Route path="/qr" element={<QRCodePage />} />
|
|
11
|
+
<Route path="/mobileroute" element={<MobileRoute />} />
|
|
12
|
+
</Routes>
|
|
13
|
+
</BrowserRouter>
|
|
14
|
+
);
|
|
11
15
|
}
|
|
12
16
|
|
|
13
|
-
export default App
|
|
17
|
+
export default App;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { KycProvider } from '../contexts/KycContext';
|
|
3
|
+
import MobileRoute from '../pages/MobileRoute';
|
|
4
|
+
import QRCodePage from '../pages/QRCodePage';
|
|
5
|
+
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
|
6
|
+
|
|
7
|
+
export interface KycFlowProps {
|
|
8
|
+
apiBaseUrl: string;
|
|
9
|
+
sessionId: string;
|
|
10
|
+
serverKey: string;
|
|
11
|
+
deviceType?: string;
|
|
12
|
+
startAtQr?: boolean;
|
|
13
|
+
onClose?: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const KycFlow: React.FC<KycFlowProps> = ({
|
|
17
|
+
apiBaseUrl,
|
|
18
|
+
sessionId,
|
|
19
|
+
serverKey,
|
|
20
|
+
deviceType,
|
|
21
|
+
startAtQr = true,
|
|
22
|
+
onClose,
|
|
23
|
+
}) => {
|
|
24
|
+
return (
|
|
25
|
+
<KycProvider
|
|
26
|
+
apiBaseUrl={apiBaseUrl}
|
|
27
|
+
sessionId={sessionId}
|
|
28
|
+
serverKey={serverKey}
|
|
29
|
+
deviceType={deviceType}
|
|
30
|
+
>
|
|
31
|
+
<BrowserRouter>
|
|
32
|
+
<Routes>
|
|
33
|
+
<Route path="/" element={<Navigate to={startAtQr ? "/qr" : "/mobileroute"} replace />} />
|
|
34
|
+
<Route path="/qr" element={<QRCodePage onClose={onClose} />} />
|
|
35
|
+
<Route path="/mobileroute" element={<MobileRoute onClose={onClose} />} />
|
|
36
|
+
</Routes>
|
|
37
|
+
</BrowserRouter>
|
|
38
|
+
</KycProvider>
|
|
39
|
+
);
|
|
40
|
+
};
|
|
41
|
+
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import React, { createContext, useContext, ReactNode } from 'react';
|
|
2
|
+
import { KycApiService, type KycApiConfig } from '../services/kycApiService';
|
|
3
|
+
|
|
4
|
+
interface KycContextValue {
|
|
5
|
+
apiService: KycApiService | null;
|
|
6
|
+
setApiConfig: (config: KycApiConfig) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const KycContext = createContext<KycContextValue>({
|
|
10
|
+
apiService: null,
|
|
11
|
+
setApiConfig: () => {},
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const useKycContext = () => {
|
|
15
|
+
const context = useContext(KycContext);
|
|
16
|
+
if (!context) {
|
|
17
|
+
throw new Error('useKycContext must be used within KycProvider');
|
|
18
|
+
}
|
|
19
|
+
return context;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
interface KycProviderProps {
|
|
23
|
+
children: ReactNode;
|
|
24
|
+
apiBaseUrl: string;
|
|
25
|
+
sessionId: string;
|
|
26
|
+
serverKey: string;
|
|
27
|
+
deviceType?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const KycProvider: React.FC<KycProviderProps> = ({
|
|
31
|
+
children,
|
|
32
|
+
apiBaseUrl,
|
|
33
|
+
sessionId,
|
|
34
|
+
serverKey,
|
|
35
|
+
deviceType,
|
|
36
|
+
}) => {
|
|
37
|
+
const [apiService, setApiService] = React.useState<KycApiService | null>(null);
|
|
38
|
+
|
|
39
|
+
React.useEffect(() => {
|
|
40
|
+
if (apiBaseUrl && sessionId && serverKey) {
|
|
41
|
+
const service = new KycApiService({
|
|
42
|
+
apiBaseUrl,
|
|
43
|
+
sessionId,
|
|
44
|
+
serverKey,
|
|
45
|
+
deviceType,
|
|
46
|
+
});
|
|
47
|
+
setApiService(service);
|
|
48
|
+
}
|
|
49
|
+
}, [apiBaseUrl, sessionId, serverKey, deviceType]);
|
|
50
|
+
|
|
51
|
+
const setApiConfig = React.useCallback((config: KycApiConfig) => {
|
|
52
|
+
if (apiService) {
|
|
53
|
+
apiService.updateConfig(config);
|
|
54
|
+
} else {
|
|
55
|
+
const service = new KycApiService(config);
|
|
56
|
+
setApiService(service);
|
|
57
|
+
}
|
|
58
|
+
}, [apiService]);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<KycContext.Provider value={{ apiService, setApiConfig }}>
|
|
62
|
+
{children}
|
|
63
|
+
</KycContext.Provider>
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from 'react';
|
|
2
|
+
import type { DocumentUploadCallbacks } from '../types';
|
|
3
|
+
import type { DocumentUploadState } from '../types';
|
|
4
|
+
|
|
5
|
+
export interface DocumentUploadHookCallbacks extends DocumentUploadCallbacks {
|
|
6
|
+
onDocumentUpload?: (blob: Blob, docType: string) => Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function useDocumentUpload(callbacks?: DocumentUploadHookCallbacks) {
|
|
10
|
+
const [state, setState] = useState<DocumentUploadState>({
|
|
11
|
+
docType: 'CNIC',
|
|
12
|
+
isDocScanMode: false,
|
|
13
|
+
docPreviewUrl: null,
|
|
14
|
+
docFileName: '',
|
|
15
|
+
loading: false,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
19
|
+
const docVideoRef = useRef<HTMLVideoElement>(null);
|
|
20
|
+
const docStreamRef = useRef<MediaStream | null>(null);
|
|
21
|
+
const docPendingBlobRef = useRef<Blob | null>(null);
|
|
22
|
+
|
|
23
|
+
const getRearStream = async (): Promise<MediaStream> => {
|
|
24
|
+
const attempts: MediaStreamConstraints[] = [
|
|
25
|
+
{ video: { facingMode: { exact: 'environment' }, width: { ideal: 1280 }, height: { ideal: 1920 } }, audio: false } as MediaStreamConstraints,
|
|
26
|
+
{ video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 1920 } }, audio: false } as unknown as MediaStreamConstraints,
|
|
27
|
+
{ video: { facingMode: { exact: 'environment' }, width: { ideal: 720 }, height: { ideal: 1280 } }, audio: false } as MediaStreamConstraints,
|
|
28
|
+
{ video: { facingMode: 'environment', width: { ideal: 720 }, height: { ideal: 1280 } }, audio: false } as unknown as MediaStreamConstraints,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
for (const c of attempts) {
|
|
32
|
+
try { return await navigator.mediaDevices.getUserMedia(c); } catch { /* continue */ }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let temp: MediaStream | null = null;
|
|
36
|
+
try {
|
|
37
|
+
temp = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
|
|
38
|
+
} catch (e) {
|
|
39
|
+
throw e;
|
|
40
|
+
} finally {
|
|
41
|
+
try { temp?.getTracks().forEach(t => t.stop()); } catch {}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
45
|
+
const videoInputs = devices.filter(d => d.kind === 'videoinput');
|
|
46
|
+
const rear = videoInputs.find(d => /back|rear|environment/i.test(d.label));
|
|
47
|
+
const chosen = rear || videoInputs.find(d => !/front|user|face/i.test(d.label)) || videoInputs[0];
|
|
48
|
+
if (!chosen) throw new Error('No video input devices found');
|
|
49
|
+
|
|
50
|
+
const byDeviceAttempts: MediaStreamConstraints[] = [
|
|
51
|
+
{ video: { deviceId: { exact: chosen.deviceId }, width: { ideal: 1280 }, height: { ideal: 1920 } }, audio: false },
|
|
52
|
+
{ video: { deviceId: { exact: chosen.deviceId }, width: { ideal: 720 }, height: { ideal: 1280 } }, audio: false },
|
|
53
|
+
{ video: { deviceId: { exact: chosen.deviceId } }, audio: false } as MediaStreamConstraints,
|
|
54
|
+
];
|
|
55
|
+
for (const c of byDeviceAttempts) {
|
|
56
|
+
try { return await navigator.mediaDevices.getUserMedia(c); } catch { /* continue */ }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const startDocCamera = async () => {
|
|
63
|
+
try {
|
|
64
|
+
if (docStreamRef.current) {
|
|
65
|
+
try { docStreamRef.current.getTracks().forEach(t => t.stop()); } catch {}
|
|
66
|
+
docStreamRef.current = null;
|
|
67
|
+
}
|
|
68
|
+
const stream = await getRearStream();
|
|
69
|
+
if (docVideoRef.current) {
|
|
70
|
+
docVideoRef.current.srcObject = stream;
|
|
71
|
+
await docVideoRef.current.play().catch(() => {});
|
|
72
|
+
}
|
|
73
|
+
docStreamRef.current = stream;
|
|
74
|
+
} catch (e: any) {
|
|
75
|
+
console.error('Could not start document camera:', e);
|
|
76
|
+
setState(prev => ({ ...prev, isDocScanMode: false }));
|
|
77
|
+
if (callbacks?.onError) {
|
|
78
|
+
callbacks.onError(e);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const handleDocumentUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
84
|
+
if (state.loading) return;
|
|
85
|
+
const file = event.target.files?.[0];
|
|
86
|
+
if (!file) return;
|
|
87
|
+
setState(prev => ({ ...prev, docFileName: file.name || '' }));
|
|
88
|
+
try {
|
|
89
|
+
const objectUrl = URL.createObjectURL(file);
|
|
90
|
+
setState(prev => ({ ...prev, docPreviewUrl: objectUrl }));
|
|
91
|
+
docPendingBlobRef.current = file;
|
|
92
|
+
} catch (err: any) {
|
|
93
|
+
console.error('Could not preview document:', err);
|
|
94
|
+
if (callbacks?.onError) {
|
|
95
|
+
callbacks.onError(err);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const handleConfirmDocumentUpload = async () => {
|
|
101
|
+
if (state.loading || !docPendingBlobRef.current) return;
|
|
102
|
+
setState(prev => ({ ...prev, loading: true }));
|
|
103
|
+
try {
|
|
104
|
+
const file = docPendingBlobRef.current as File;
|
|
105
|
+
|
|
106
|
+
// Upload document if callback provided
|
|
107
|
+
if (callbacks?.onDocumentUpload) {
|
|
108
|
+
try {
|
|
109
|
+
await callbacks.onDocumentUpload(file, state.docType);
|
|
110
|
+
} catch (uploadError: any) {
|
|
111
|
+
throw new Error(uploadError.message || 'Failed to upload document');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (callbacks?.onUpload) {
|
|
116
|
+
callbacks.onUpload(file, state.docType);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
setState(prev => ({
|
|
120
|
+
...prev,
|
|
121
|
+
docPreviewUrl: null,
|
|
122
|
+
docFileName: '',
|
|
123
|
+
loading: false,
|
|
124
|
+
}));
|
|
125
|
+
docPendingBlobRef.current = null;
|
|
126
|
+
} catch (err: any) {
|
|
127
|
+
console.error('Document upload failed:', err);
|
|
128
|
+
setState(prev => ({ ...prev, loading: false }));
|
|
129
|
+
if (callbacks?.onError) {
|
|
130
|
+
callbacks.onError(err);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const handleManualCapture = async () => {
|
|
136
|
+
if (!docVideoRef.current || state.loading) return;
|
|
137
|
+
|
|
138
|
+
setState(prev => ({ ...prev, loading: true }));
|
|
139
|
+
try {
|
|
140
|
+
const video = docVideoRef.current;
|
|
141
|
+
|
|
142
|
+
// Create canvas and capture current frame
|
|
143
|
+
const canvas = document.createElement('canvas');
|
|
144
|
+
const width = video.videoWidth || 1280;
|
|
145
|
+
const height = video.videoHeight || 720;
|
|
146
|
+
canvas.width = width;
|
|
147
|
+
canvas.height = height;
|
|
148
|
+
|
|
149
|
+
const ctx = canvas.getContext('2d');
|
|
150
|
+
if (!ctx) throw new Error('Canvas not supported');
|
|
151
|
+
|
|
152
|
+
// Draw video frame to canvas
|
|
153
|
+
ctx.drawImage(video, 0, 0, width, height);
|
|
154
|
+
|
|
155
|
+
// Convert to blob
|
|
156
|
+
const blob = await new Promise<Blob>((resolve) => {
|
|
157
|
+
canvas.toBlob((blob: Blob | null) => {
|
|
158
|
+
resolve(blob || new Blob());
|
|
159
|
+
}, 'image/jpeg', 0.92);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const file = new File([blob], 'document.jpg', { type: 'image/jpeg' });
|
|
163
|
+
|
|
164
|
+
// Upload document if callback provided
|
|
165
|
+
if (callbacks?.onDocumentUpload) {
|
|
166
|
+
try {
|
|
167
|
+
await callbacks.onDocumentUpload(file, state.docType);
|
|
168
|
+
} catch (uploadError: any) {
|
|
169
|
+
throw new Error(uploadError.message || 'Failed to upload document');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Create preview URL
|
|
174
|
+
const objectUrl = URL.createObjectURL(file);
|
|
175
|
+
setState(prev => ({
|
|
176
|
+
...prev,
|
|
177
|
+
docPreviewUrl: objectUrl,
|
|
178
|
+
isDocScanMode: false,
|
|
179
|
+
loading: false,
|
|
180
|
+
}));
|
|
181
|
+
|
|
182
|
+
docPendingBlobRef.current = file;
|
|
183
|
+
|
|
184
|
+
// Stop camera
|
|
185
|
+
if (docStreamRef.current) {
|
|
186
|
+
docStreamRef.current.getTracks().forEach((t) => t.stop());
|
|
187
|
+
docStreamRef.current = null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (callbacks?.onScan) {
|
|
191
|
+
callbacks.onScan(file, state.docType);
|
|
192
|
+
}
|
|
193
|
+
} catch (err: any) {
|
|
194
|
+
console.error('Capture failed:', err);
|
|
195
|
+
setState(prev => ({ ...prev, loading: false }));
|
|
196
|
+
if (callbacks?.onError) {
|
|
197
|
+
callbacks.onError(err);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
if (state.isDocScanMode) {
|
|
204
|
+
startDocCamera();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return () => {
|
|
208
|
+
if (docStreamRef.current) {
|
|
209
|
+
docStreamRef.current.getTracks().forEach((t) => t.stop());
|
|
210
|
+
docStreamRef.current = null;
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
}, [state.isDocScanMode]);
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
state,
|
|
217
|
+
setState,
|
|
218
|
+
fileInputRef,
|
|
219
|
+
docVideoRef,
|
|
220
|
+
handleDocumentUpload,
|
|
221
|
+
handleConfirmDocumentUpload,
|
|
222
|
+
handleManualCapture,
|
|
223
|
+
startDocCamera,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type DocumentType = 'CNIC' | 'Passport' | 'DrivingLicense';
|
|
2
|
+
|
|
3
|
+
export interface DocumentUploadState {
|
|
4
|
+
docType: DocumentType;
|
|
5
|
+
isDocScanMode: boolean;
|
|
6
|
+
docPreviewUrl: string | null;
|
|
7
|
+
docFileName: string;
|
|
8
|
+
loading: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DocumentUploadCallbacks {
|
|
12
|
+
onUpload?: (file: File, docType: DocumentType) => void;
|
|
13
|
+
onScan?: (file: File, docType: DocumentType) => void;
|
|
14
|
+
onError?: (error: Error) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useRef, useState, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
export function useCamera() {
|
|
4
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
5
|
+
const streamRef = useRef<MediaStream | null>(null);
|
|
6
|
+
const [cameraReady, setCameraReady] = useState(false);
|
|
7
|
+
|
|
8
|
+
const startCamera = async () => {
|
|
9
|
+
try {
|
|
10
|
+
const stream = await navigator.mediaDevices.getUserMedia({
|
|
11
|
+
video: {
|
|
12
|
+
facingMode: "user",
|
|
13
|
+
width: { ideal: 1280 },
|
|
14
|
+
height: { ideal: 720 }
|
|
15
|
+
},
|
|
16
|
+
audio: false
|
|
17
|
+
});
|
|
18
|
+
if (videoRef.current) {
|
|
19
|
+
videoRef.current.srcObject = stream;
|
|
20
|
+
await videoRef.current.play().catch(() => { });
|
|
21
|
+
requestAnimationFrame(() => {
|
|
22
|
+
try {
|
|
23
|
+
const v = videoRef.current!;
|
|
24
|
+
// Canvas will be synced separately
|
|
25
|
+
} catch { }
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
streamRef.current = stream;
|
|
29
|
+
setCameraReady(true);
|
|
30
|
+
} catch (e: any) {
|
|
31
|
+
console.error('Camera access error:', e);
|
|
32
|
+
setCameraReady(false);
|
|
33
|
+
throw e;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const stopCamera = () => {
|
|
38
|
+
if (streamRef.current) {
|
|
39
|
+
streamRef.current.getTracks().forEach((t) => t.stop());
|
|
40
|
+
streamRef.current = null;
|
|
41
|
+
}
|
|
42
|
+
if (videoRef.current) {
|
|
43
|
+
videoRef.current.srcObject = null;
|
|
44
|
+
}
|
|
45
|
+
setCameraReady(false);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
startCamera();
|
|
50
|
+
return () => {
|
|
51
|
+
stopCamera();
|
|
52
|
+
};
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
videoRef,
|
|
57
|
+
cameraReady,
|
|
58
|
+
startCamera,
|
|
59
|
+
stopCamera,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|