@viji-dev/sdk 1.0.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.
Files changed (55) hide show
  1. package/.gitignore +29 -0
  2. package/LICENSE +13 -0
  3. package/README.md +103 -0
  4. package/bin/viji.js +75 -0
  5. package/eslint.config.js +37 -0
  6. package/index.html +20 -0
  7. package/package.json +82 -0
  8. package/postcss.config.js +6 -0
  9. package/public/favicon.png +0 -0
  10. package/scenes/audio-visualizer/main.js +287 -0
  11. package/scenes/core-demo/main.js +532 -0
  12. package/scenes/demo-scene/main.js +619 -0
  13. package/scenes/global.d.ts +15 -0
  14. package/scenes/particle-system/main.js +349 -0
  15. package/scenes/tsconfig.json +12 -0
  16. package/scenes/video-mirror/main.ts +436 -0
  17. package/src/App.css +42 -0
  18. package/src/App.tsx +279 -0
  19. package/src/cli/commands/build.js +147 -0
  20. package/src/cli/commands/create.js +71 -0
  21. package/src/cli/commands/dev.js +108 -0
  22. package/src/cli/commands/init.js +262 -0
  23. package/src/cli/utils/cli-utils.js +208 -0
  24. package/src/cli/utils/scene-compiler.js +432 -0
  25. package/src/components/SDKPage.tsx +337 -0
  26. package/src/components/core/CoreContainer.tsx +126 -0
  27. package/src/components/ui/DeviceSelectionList.tsx +137 -0
  28. package/src/components/ui/FPSCounter.tsx +78 -0
  29. package/src/components/ui/FileDropzonePanel.tsx +120 -0
  30. package/src/components/ui/FileListPanel.tsx +285 -0
  31. package/src/components/ui/InputExpansionPanel.tsx +31 -0
  32. package/src/components/ui/MediaPlayerControls.tsx +191 -0
  33. package/src/components/ui/MenuContainer.tsx +71 -0
  34. package/src/components/ui/ParametersMenu.tsx +797 -0
  35. package/src/components/ui/ProjectSwitcherMenu.tsx +192 -0
  36. package/src/components/ui/QuickInputControls.tsx +542 -0
  37. package/src/components/ui/SDKMenuSystem.tsx +96 -0
  38. package/src/components/ui/SettingsMenu.tsx +346 -0
  39. package/src/components/ui/SimpleInputControls.tsx +137 -0
  40. package/src/index.css +68 -0
  41. package/src/main.tsx +10 -0
  42. package/src/scenes-hmr.ts +158 -0
  43. package/src/services/project-filesystem.ts +436 -0
  44. package/src/stores/scene-player/index.ts +3 -0
  45. package/src/stores/scene-player/input-manager.store.ts +1045 -0
  46. package/src/stores/scene-player/scene-session.store.ts +659 -0
  47. package/src/styles/globals.css +111 -0
  48. package/src/templates/minimal-template.js +11 -0
  49. package/src/utils/debounce.js +34 -0
  50. package/src/vite-env.d.ts +1 -0
  51. package/tailwind.config.js +18 -0
  52. package/tsconfig.app.json +27 -0
  53. package/tsconfig.json +27 -0
  54. package/tsconfig.node.json +27 -0
  55. package/vite.config.ts +54 -0
@@ -0,0 +1,337 @@
1
+ import React, { useRef, useState, useEffect } from 'react';
2
+ import CoreContainer from './core/CoreContainer';
3
+ import FPSCounter from './ui/FPSCounter';
4
+ import SDKMenuSystem from './ui/SDKMenuSystem';
5
+ import QuickInputControls from './ui/QuickInputControls';
6
+ import { useSceneSessionStore } from '../stores/scene-player/scene-session.store';
7
+
8
+ interface Project {
9
+ id: string;
10
+ name: string;
11
+ path: string;
12
+ mainFile: string;
13
+ lastModified: Date;
14
+ sceneType: 'javascript' | 'typescript' | 'shader';
15
+ isBuilt: boolean;
16
+ }
17
+
18
+ interface SDKPageProps {
19
+ sceneCode?: string;
20
+ onSceneError?: (error: string) => void;
21
+ currentProject?: Project;
22
+ onProjectSwitch?: (project: Project) => void;
23
+ // Note: UI CRUD props removed - onProjectCreate, onProjectDelete, onProjectRename no longer used
24
+ }
25
+
26
+ const SDKPage: React.FC<SDKPageProps> = ({
27
+ sceneCode,
28
+ onSceneError,
29
+ currentProject,
30
+ onProjectSwitch,
31
+ }) => {
32
+ const coreContainerRef = useRef<HTMLDivElement>(null);
33
+ const lastAppliedCodeRef = useRef<string | null>(null);
34
+ const lastAppliedHashRef = useRef<string | null>(null);
35
+
36
+ // Fast, stable 32-bit FNV-1a hash as hex
37
+ const hashString = (str: string): string => {
38
+ let hash = 2166136261; // FNV offset basis
39
+ for (let i = 0; i < str.length; i++) {
40
+ hash ^= str.charCodeAt(i);
41
+ hash = (hash * 16777619) >>> 0; // FNV prime, convert to unsigned 32-bit
42
+ }
43
+ return hash.toString(16);
44
+ };
45
+ const [showFPS] = useState(false); // TODO: Make this configurable via Settings menu
46
+ const [resolution] = useState(1.0); // TODO: Make this configurable via Settings menu
47
+ const [frameRateMode] = useState<'full' | 'half'>('full'); // TODO: Make this configurable via Settings menu
48
+
49
+ // Debug: Log when sceneCode prop changes (simplified - HMR working)
50
+ useEffect(() => {
51
+ console.log('🔄 [SDKPage] sceneCode prop changed:', {
52
+ len: sceneCode?.length || 0,
53
+ hash: sceneCode ? hashString(sceneCode) : 'none'
54
+ });
55
+ }, [sceneCode, currentProject?.id]);
56
+
57
+ // Use session store for managing the scene session
58
+ const {
59
+ session,
60
+ createSession,
61
+ initializeCore,
62
+ updateSession,
63
+ clearSession,
64
+ destroyCore,
65
+ isInitializingCore,
66
+ coreReady
67
+ } = useSceneSessionStore();
68
+
69
+ // Create session when project changes (with race condition prevention)
70
+ useEffect(() => {
71
+ let isCurrentEffect = true; // Prevent race conditions
72
+
73
+ const switchProject = async () => {
74
+ if (!isCurrentEffect) return;
75
+
76
+ if (!currentProject) {
77
+ if (session) {
78
+ console.log('🧹 Clearing session - no current project');
79
+ await clearSession();
80
+ }
81
+ return;
82
+ }
83
+
84
+ // Only create new session if project changed and effect is still current
85
+ if ((!session || session.sceneId !== currentProject.id) && isCurrentEffect) {
86
+ // Require scene code to be ready for this project before creating session
87
+ if (!sceneCode || sceneCode.trim().length === 0 || !/function\s+render\s*\(/.test(sceneCode)) {
88
+ // Wait until valid scene code is provided via props
89
+ return;
90
+ }
91
+ // Clear old session first to properly destroy previous VijiCore instance
92
+ if (session) {
93
+ console.log('🧹 Clearing previous session for:', session.sceneId);
94
+ await clearSession();
95
+ // Reset hash references when switching projects to prevent false reinit
96
+ lastAppliedCodeRef.current = null;
97
+ lastAppliedHashRef.current = null;
98
+ }
99
+
100
+ // Double check effect is still current after async operations
101
+ if (!isCurrentEffect) return;
102
+
103
+ // IMPORTANT: Do NOT reset the input manager here. We want inputs to persist across scenes.
104
+ // Frontend visualizer preserves live input state between scene switches.
105
+
106
+ console.log('🔄 Creating SDK session for new project:', currentProject.name);
107
+ await createSession(currentProject.id, sceneCode, currentProject.name);
108
+ console.log('✅ SDK session created successfully for:', currentProject.name);
109
+ }
110
+ };
111
+
112
+ switchProject().catch(console.error);
113
+
114
+ // Cleanup function to prevent race conditions
115
+ return () => {
116
+ isCurrentEffect = false;
117
+ };
118
+ }, [currentProject?.id, session?.sceneId, sceneCode, createSession, clearSession]); // More specific dependencies
119
+
120
+ // Update session when scene code changes (only for the active project)
121
+ useEffect(() => {
122
+ if (!session || !sceneCode) return;
123
+ // Avoid applying code from a different project to current session
124
+ if (!currentProject || session.sceneId !== currentProject.id) return;
125
+ if (session.sceneCode !== sceneCode) {
126
+ const prevHash = hashString(session.sceneCode || '');
127
+ const nextHash = hashString(sceneCode);
128
+ console.log('🔄 Updating scene code for session:', session.sceneId, {
129
+ prevLen: session.sceneCode?.length || 0,
130
+ nextLen: sceneCode.length,
131
+ changed: session.sceneCode !== sceneCode,
132
+ prevHash,
133
+ nextHash,
134
+ equalsFirstChars: session.sceneCode?.slice(0, 64) === sceneCode.slice(0, 64)
135
+ });
136
+ updateSession({ sceneCode });
137
+ } else {
138
+ console.log('⏭️ Scene code unchanged (no update applied):', session.sceneId, {
139
+ len: sceneCode.length
140
+ });
141
+ }
142
+ }, [session?.sceneId, currentProject?.id, sceneCode, updateSession]);
143
+
144
+ // When scene code changes for the active project and a core is running, reinitialize core (like Editor refresh)
145
+ useEffect(() => {
146
+ const reinit = async () => {
147
+ console.log('🔄 [SDKPage] Scene code effect triggered:', {
148
+ hasSession: !!session,
149
+ hasSceneCode: !!sceneCode,
150
+ sceneCodeLen: sceneCode?.length || 0,
151
+ sceneCodeHash: sceneCode ? hashString(sceneCode) : 'none',
152
+ currentProject: currentProject?.id,
153
+ sessionSceneId: session?.sceneId,
154
+ hasCoreInstance: !!session?.coreInstance,
155
+ coreReady,
156
+ isInitializing: isInitializingCore
157
+ });
158
+
159
+ if (!session || !sceneCode) return;
160
+ if (!currentProject || session.sceneId !== currentProject.id) return;
161
+ if (!session.coreInstance) return;
162
+ // Avoid race with ongoing initialization; only reinit when core is fully ready
163
+ if (isInitializingCore || !coreReady) return;
164
+ // Initial core ready with no applied marker: record and skip
165
+ if (lastAppliedCodeRef.current === null) {
166
+ lastAppliedCodeRef.current = session.sceneCode;
167
+ lastAppliedHashRef.current = hashString(session.sceneCode || '');
168
+ console.log('🔄 [SDKPage] Initial code recorded:', {
169
+ len: session.sceneCode?.length || 0,
170
+ hash: lastAppliedHashRef.current
171
+ });
172
+ return;
173
+ }
174
+ // Only reinit when new code differs from what core has last applied
175
+ const newHash = hashString(sceneCode);
176
+ if (lastAppliedHashRef.current === newHash) {
177
+ console.log('⏭️ Reinit skipped: code already applied');
178
+ return;
179
+ }
180
+
181
+ // Only reinitialize if code contains a render function to prevent worker errors
182
+ if (!/function\s+render\s*\(/.test(sceneCode)) return;
183
+
184
+ console.log('♻️ Reinitializing core due to scene code update for:', session.sceneId, {
185
+ lastAppliedLen: lastAppliedCodeRef.current?.length || 0,
186
+ newLen: sceneCode.length,
187
+ lastAppliedHash: lastAppliedHashRef.current,
188
+ newHash
189
+ });
190
+ try {
191
+ // Mark new code as in-flight to prevent loops
192
+ lastAppliedCodeRef.current = sceneCode;
193
+ lastAppliedHashRef.current = newHash;
194
+ await destroyCore();
195
+ // initCore effect below will pick up and initialize with new code
196
+ } catch (e) {
197
+ console.error('Failed to reinitialize core:', e);
198
+ }
199
+ };
200
+ reinit();
201
+ }, [sceneCode, currentProject?.id, session?.sceneId, session?.coreInstance, session?.sceneCode, isInitializingCore, coreReady, destroyCore]);
202
+
203
+ // Update core config when settings change
204
+ useEffect(() => {
205
+ if (session) {
206
+ updateSession({
207
+ coreConfig: {
208
+ resolution,
209
+ frameRateMode,
210
+ allowUserInteraction: true
211
+ }
212
+ });
213
+ }
214
+ }, [session?.sceneId, resolution, frameRateMode, updateSession]); // Only run when session ID or settings change
215
+
216
+ // Initialize core when session is ready (with race condition prevention)
217
+ useEffect(() => {
218
+ let isCurrentEffect = true; // Prevent race conditions
219
+
220
+ const initCore = async () => {
221
+ if (!isCurrentEffect) return;
222
+
223
+ // Only initialize if this effect is still current and conditions are met
224
+ if (!session || !session.sceneCode || session.coreInstance || !coreContainerRef.current) {
225
+ return;
226
+ }
227
+ // Ensure scene code contains a render() function to avoid worker errors
228
+ if (!/function\s+render\s*\(/.test(session.sceneCode)) {
229
+ return;
230
+ }
231
+
232
+ const container = coreContainerRef.current.querySelector('#viji-core-host') as HTMLElement;
233
+ if (!container) {
234
+ console.warn('🚫 Skipping Core initialization: container not ready');
235
+ return;
236
+ }
237
+
238
+ try {
239
+ console.log('🚀 Initializing core for session:', session.sceneId);
240
+ await initializeCore(container);
241
+
242
+ if (isCurrentEffect) {
243
+ console.log('✅ Core initialized successfully for:', session.sceneId);
244
+ // After successful init, record the code currently running in core
245
+ try {
246
+ lastAppliedCodeRef.current = session.sceneCode;
247
+ lastAppliedHashRef.current = hashString(session.sceneCode || '');
248
+ } catch {}
249
+ }
250
+ } catch (error) {
251
+ if (isCurrentEffect) {
252
+ console.error('❌ Failed to initialize core:', error);
253
+ onSceneError?.(error instanceof Error ? error.message : 'Failed to initialize scene');
254
+ }
255
+ }
256
+ };
257
+
258
+ initCore();
259
+
260
+ // Cleanup function to prevent race conditions
261
+ return () => {
262
+ isCurrentEffect = false;
263
+ };
264
+ }, [session?.sceneId, session?.sceneCode, session?.coreInstance, initializeCore, onSceneError]); // Re-run when session changes
265
+
266
+ // Settings and FPS toggle handlers would be implemented when needed
267
+ // const handleSettingsChange = (settings: any) => {
268
+ // if (settings.resolution !== undefined) setResolution(settings.resolution);
269
+ // if (settings.frameRateMode !== undefined) setFrameRateMode(settings.frameRateMode);
270
+ // };
271
+ // const handleFPSToggle = (show: boolean) => setShowFPS(show);
272
+
273
+ return (
274
+ <div className="fixed inset-0 bg-black overflow-hidden">
275
+ {/* Core container - Full screen since no editor sidebar */}
276
+ <div className="w-full h-full relative">
277
+ {/* Core host */}
278
+ <div
279
+ ref={coreContainerRef}
280
+ className="w-full h-full"
281
+ >
282
+ {session ? (
283
+ <CoreContainer
284
+ session={session}
285
+ className="w-full h-full"
286
+ onError={onSceneError}
287
+ />
288
+ ) : (
289
+ <div className="w-full h-full flex items-center justify-center text-white/60">
290
+ <div className="text-center">
291
+ <div className="text-white">No scene loaded</div>
292
+ <div className="text-sm">Switch to a project to load a scene</div>
293
+ </div>
294
+ </div>
295
+ )}
296
+ </div>
297
+
298
+ {/* Overlay: top header with logo */}
299
+ <div className="absolute top-0 left-0 right-0 z-50 flex items-center justify-center p-4 pointer-events-none">
300
+ <div className="flex items-center justify-center pointer-events-auto">
301
+ <div className="bg-black/20 backdrop-blur-sm rounded-lg px-4 py-2 border border-white/10">
302
+ <div className="flex items-center space-x-2">
303
+ <div className="w-8 h-8 bg-gradient-to-br from-primary-400 to-primary-600 rounded-lg flex items-center justify-center">
304
+ <span className="text-white font-bold text-sm">V</span>
305
+ </div>
306
+ <span className="text-white font-semibold tracking-wide">Viji SDK</span>
307
+ </div>
308
+ </div>
309
+ </div>
310
+ </div>
311
+
312
+ {/* Menu system - Projects, Parameters, Settings (always visible like in play mode) */}
313
+ <SDKMenuSystem
314
+ currentProject={currentProject}
315
+ onProjectSwitch={onProjectSwitch}
316
+ />
317
+
318
+ {/* Bottom-right input controls - place directly to avoid full-screen overlay capturing clicks */}
319
+ <QuickInputControls
320
+ className="bottom-4 right-4 z-60"
321
+ onSetControlsPinned={(pinned) => {
322
+ console.log('🔧 Controls pinned:', pinned);
323
+ }}
324
+ />
325
+
326
+ {/* FPS Counter - bottom center */}
327
+ <FPSCounter
328
+ className="bottom-6 absolute left-1/2 transform -translate-x-1/2"
329
+ show={showFPS}
330
+ coreInstance={session?.coreInstance}
331
+ />
332
+ </div>
333
+ </div>
334
+ );
335
+ };
336
+
337
+ export default SDKPage;
@@ -0,0 +1,126 @@
1
+ import { forwardRef, useEffect, useRef } from 'react';
2
+ import type { ScenePlayerSession } from '../../stores/scene-player/scene-session.store';
3
+
4
+ interface CoreContainerProps {
5
+ className?: string;
6
+ session: ScenePlayerSession;
7
+ onError?: (error: string) => void;
8
+ }
9
+
10
+ const CoreContainer = forwardRef<HTMLDivElement, CoreContainerProps>(
11
+ ({ className = '', session, onError }, ref) => {
12
+ const mountedRef = useRef(false);
13
+ const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
14
+ const containerRef = useRef<HTMLDivElement>(null);
15
+
16
+ const scheduleResolutionUpdate = () => {
17
+ if (!session.coreInstance || !mountedRef.current) return;
18
+ // Avoid propagating zero-size updates that can destabilize the core
19
+ const el = containerRef.current;
20
+ if (!el || el.clientWidth === 0 || el.clientHeight === 0) return;
21
+ if (resizeTimeoutRef.current) {
22
+ clearTimeout(resizeTimeoutRef.current);
23
+ }
24
+ resizeTimeoutRef.current = setTimeout(async () => {
25
+ const elt = containerRef.current;
26
+ if (
27
+ session.coreInstance &&
28
+ mountedRef.current &&
29
+ elt &&
30
+ elt.clientWidth > 0 &&
31
+ elt.clientHeight > 0
32
+ ) {
33
+ try {
34
+ await session.coreInstance.updateResolution();
35
+ } catch (error) {
36
+ console.warn('⚠️ Error updating Core resolution:', error);
37
+ onError?.('Failed to update scene resolution');
38
+ }
39
+ }
40
+ }, 50);
41
+ };
42
+
43
+ // Handle window resize events with debouncing
44
+ useEffect(() => {
45
+ const handleResize = () => scheduleResolutionUpdate();
46
+ window.addEventListener('resize', handleResize);
47
+ return () => {
48
+ window.removeEventListener('resize', handleResize);
49
+ if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current);
50
+ };
51
+ }, [session.coreInstance]);
52
+
53
+ // Observe container size changes (e.g., padding changes when editor sidebar resizes)
54
+ useEffect(() => {
55
+ if (!containerRef.current) return;
56
+ if (!(window as any).ResizeObserver) return;
57
+ const ro = new (window as any).ResizeObserver(() => {
58
+ scheduleResolutionUpdate();
59
+ });
60
+ ro.observe(containerRef.current);
61
+ return () => {
62
+ try { ro.disconnect(); } catch {}
63
+ };
64
+ // eslint-disable-next-line react-hooks/exhaustive-deps
65
+ }, [containerRef.current, session.coreInstance]);
66
+
67
+ // Handle core errors
68
+ useEffect(() => {
69
+ if (session.coreInstance && mountedRef.current) {
70
+ // Listen for core errors if the API supports it
71
+ // This would depend on the actual @viji-dev/core API
72
+ try {
73
+ // session.coreInstance.onError?.(onError);
74
+ } catch (error) {
75
+ console.warn('Core error handling not available:', error);
76
+ }
77
+ }
78
+ }, [session.coreInstance, onError]);
79
+
80
+ // Track mounting state
81
+ useEffect(() => {
82
+ mountedRef.current = true;
83
+ return () => {
84
+ mountedRef.current = false;
85
+ };
86
+ }, []);
87
+
88
+ return (
89
+ <div
90
+ ref={containerRef}
91
+ className={`relative bg-black ${className}`}
92
+ style={{
93
+ width: '100%',
94
+ height: '100%',
95
+ overflow: 'hidden',
96
+ }}
97
+ >
98
+ {/* VijiCore Container - React hands control to VijiCore */}
99
+ <div
100
+ id="viji-core-host"
101
+ ref={ref}
102
+ className="absolute inset-0 bg-black"
103
+ style={{
104
+ width: '100%',
105
+ height: '100%',
106
+ }}
107
+ // This div is for VijiCore only - no React children!
108
+ />
109
+
110
+ {/* Loading Overlay - Managed by React, separate from VijiCore */}
111
+ {!session.coreInstance && mountedRef.current && session.sceneCode && (
112
+ <div className="absolute inset-0 flex items-center justify-center text-white/50 pointer-events-none z-10">
113
+ <div className="text-center">
114
+ <div className="w-8 h-8 border-2 border-white/20 border-t-white/60 rounded-full animate-spin mx-auto mb-4"></div>
115
+ <p>Preparing scene...</p>
116
+ </div>
117
+ </div>
118
+ )}
119
+ </div>
120
+ );
121
+ }
122
+ );
123
+
124
+ CoreContainer.displayName = 'CoreContainer';
125
+
126
+ export default CoreContainer;
@@ -0,0 +1,137 @@
1
+ import React, { useEffect } from 'react';
2
+ import { Button } from '@heroui/react';
3
+ import { CheckIcon } from '@heroicons/react/24/outline';
4
+ import { useInputManagerStore } from '../../stores/scene-player/input-manager.store';
5
+
6
+ interface DeviceSelectionListProps {
7
+ deviceType: 'audio' | 'video';
8
+ onDeviceSelect?: (deviceId: string, deviceLabel: string) => void;
9
+ }
10
+
11
+ const DeviceSelectionList: React.FC<DeviceSelectionListProps> = ({
12
+ deviceType,
13
+ onDeviceSelect
14
+ }) => {
15
+ const {
16
+ availableDevices,
17
+ inputConfiguration,
18
+ refreshAvailableDevices,
19
+ selectAudioDevice,
20
+ selectVideoDevice,
21
+ requestPermissions,
22
+ syncDevicesFromActiveStreams
23
+ } = useInputManagerStore();
24
+
25
+ const currentDeviceId = deviceType === 'audio'
26
+ ? inputConfiguration.audio.deviceId
27
+ : inputConfiguration.video.deviceId;
28
+
29
+ const devices = deviceType === 'audio'
30
+ ? availableDevices.audioDevices
31
+ : availableDevices.videoDevices;
32
+
33
+ useEffect(() => {
34
+ let cancelled = false;
35
+ const loadDevices = async () => {
36
+ // Request permissions first to ensure we can see device labels
37
+ const permissionGranted = await requestPermissions(
38
+ deviceType === 'audio',
39
+ deviceType === 'video'
40
+ );
41
+
42
+ if (!permissionGranted) return;
43
+ if (cancelled) return;
44
+ await refreshAvailableDevices();
45
+ if (cancelled) return;
46
+ // After enumeration, sync active stream device IDs to ensure first-open highlighting
47
+ syncDevicesFromActiveStreams();
48
+ };
49
+
50
+ // Slight delay to avoid clashing with popover open focus/aria changes
51
+ const t = setTimeout(() => { loadDevices().catch(console.error); }, 50);
52
+ return () => { cancelled = true; clearTimeout(t); };
53
+ }, [deviceType, requestPermissions, refreshAvailableDevices, syncDevicesFromActiveStreams]);
54
+
55
+ // Proactive sync when devices or streams change to keep highlighting correct without reopening
56
+ useEffect(() => {
57
+ syncDevicesFromActiveStreams();
58
+ }, [
59
+ syncDevicesFromActiveStreams,
60
+ availableDevices.audioDevices,
61
+ availableDevices.videoDevices,
62
+ inputConfiguration.audio.stream,
63
+ inputConfiguration.video.stream
64
+ ]);
65
+
66
+ // Debug: Track highlight changes
67
+ useEffect(() => {
68
+ console.log(`🎯 [HIGHLIGHT] ${deviceType} currentDeviceId changed`, {
69
+ deviceType,
70
+ currentDeviceId,
71
+ devices: devices.map(d => ({ id: d.deviceId, label: d.label })),
72
+ isMatchAny: devices.some(d => d.deviceId === currentDeviceId)
73
+ });
74
+ }, [currentDeviceId, deviceType]);
75
+
76
+ const handleDeviceSelect = async (deviceId: string, deviceLabel: string) => {
77
+ try {
78
+ if (deviceType === 'audio') {
79
+ await selectAudioDevice(deviceId, deviceLabel);
80
+ } else {
81
+ await selectVideoDevice(deviceId, deviceLabel);
82
+ }
83
+ onDeviceSelect?.(deviceId, deviceLabel);
84
+ } catch (error) {
85
+ console.error(`❌ [DeviceSelectionList] Failed to select ${deviceType} device:`, error);
86
+ }
87
+ };
88
+
89
+ if (devices.length === 0) {
90
+ return (
91
+ <div className="text-center py-8 text-gray-400">
92
+ <p>No {deviceType} devices found</p>
93
+ <Button
94
+ size="sm"
95
+ variant="flat"
96
+ className="mt-2 bg-white/10 text-white hover:bg-white/20"
97
+ onPress={() => refreshAvailableDevices()}
98
+ >
99
+ Refresh Devices
100
+ </Button>
101
+ </div>
102
+ );
103
+ }
104
+
105
+ return (
106
+ <div className="space-y-2">
107
+ {devices.map((device) => {
108
+ const isSelected = device.deviceId === currentDeviceId;
109
+ const deviceName = device.label || `${deviceType} Device`;
110
+
111
+ return (
112
+ <Button
113
+ key={device.deviceId}
114
+ variant="flat"
115
+ className={`w-full justify-start px-3 py-2 h-auto min-h-0 pointer-events-auto ${
116
+ isSelected
117
+ ? 'bg-white/20 text-white'
118
+ : 'bg-transparent text-gray-300 hover:bg-white/10 hover:text-white'
119
+ }`}
120
+ onPress={() => handleDeviceSelect(device.deviceId, deviceName)}
121
+ >
122
+ <div className="flex items-center w-full">
123
+ <div className="flex-shrink-0 w-5 h-5 mr-3 flex items-center justify-center">
124
+ {isSelected && (
125
+ <CheckIcon className="w-4 h-4 text-green-400" />
126
+ )}
127
+ </div>
128
+ <span className="text-left truncate">{deviceName}</span>
129
+ </div>
130
+ </Button>
131
+ );
132
+ })}
133
+ </div>
134
+ );
135
+ };
136
+
137
+ export default DeviceSelectionList;
@@ -0,0 +1,78 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+
3
+ interface FPSCounterProps {
4
+ className?: string;
5
+ show?: boolean;
6
+ coreInstance?: any;
7
+ }
8
+
9
+ const FPSCounter: React.FC<FPSCounterProps> = ({
10
+ className = '',
11
+ show = false,
12
+ coreInstance
13
+ }) => {
14
+ const [effectiveRefreshRate, setEffectiveRefreshRate] = useState<number | null>(null);
15
+ const rafRef = useRef<number | null>(null);
16
+ const lastUpdateRef = useRef<number>(0);
17
+
18
+ useEffect(() => {
19
+ if (!show) {
20
+ setEffectiveRefreshRate(null);
21
+ }
22
+ }, [show]);
23
+
24
+ useEffect(() => {
25
+ const update = (now: number) => {
26
+ if (!show) return;
27
+ // Throttle to ~2 Hz to keep overhead negligible
28
+ if (now - lastUpdateRef.current < 500) {
29
+ rafRef.current = requestAnimationFrame(update);
30
+ return;
31
+ }
32
+ lastUpdateRef.current = now;
33
+ try {
34
+ const stats = coreInstance?.getStats?.();
35
+ const value = stats?.frameRate?.effectiveRefreshRate;
36
+ if (typeof value === 'number' && !Number.isNaN(value)) {
37
+ setEffectiveRefreshRate(value);
38
+ } else {
39
+ setEffectiveRefreshRate(null);
40
+ }
41
+ } catch {
42
+ setEffectiveRefreshRate(null);
43
+ }
44
+ rafRef.current = requestAnimationFrame(update);
45
+ };
46
+
47
+ if (show) {
48
+ rafRef.current = requestAnimationFrame(update);
49
+ return () => {
50
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
51
+ rafRef.current = null;
52
+ };
53
+ }
54
+ return;
55
+ }, [show, coreInstance]);
56
+
57
+ if (!show) return null;
58
+
59
+ return (
60
+ <div
61
+ className={
62
+ `pointer-events-none absolute left-1/2 transform -translate-x-1/2 z-60 ` +
63
+ `bg-black/60 backdrop-blur-sm text-white px-3 py-1 rounded-full border border-white/20 ` +
64
+ `text-xs font-medium ${className}`
65
+ }
66
+ aria-label="Frames per second"
67
+ role="status"
68
+ >
69
+ {effectiveRefreshRate !== null ? `${Math.round(effectiveRefreshRate)} FPS` : '— FPS'}
70
+ </div>
71
+ );
72
+ };
73
+
74
+ export default FPSCounter;
75
+
76
+
77
+
78
+