@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.
- package/.gitignore +29 -0
- package/LICENSE +13 -0
- package/README.md +103 -0
- package/bin/viji.js +75 -0
- package/eslint.config.js +37 -0
- package/index.html +20 -0
- package/package.json +82 -0
- package/postcss.config.js +6 -0
- package/public/favicon.png +0 -0
- package/scenes/audio-visualizer/main.js +287 -0
- package/scenes/core-demo/main.js +532 -0
- package/scenes/demo-scene/main.js +619 -0
- package/scenes/global.d.ts +15 -0
- package/scenes/particle-system/main.js +349 -0
- package/scenes/tsconfig.json +12 -0
- package/scenes/video-mirror/main.ts +436 -0
- package/src/App.css +42 -0
- package/src/App.tsx +279 -0
- package/src/cli/commands/build.js +147 -0
- package/src/cli/commands/create.js +71 -0
- package/src/cli/commands/dev.js +108 -0
- package/src/cli/commands/init.js +262 -0
- package/src/cli/utils/cli-utils.js +208 -0
- package/src/cli/utils/scene-compiler.js +432 -0
- package/src/components/SDKPage.tsx +337 -0
- package/src/components/core/CoreContainer.tsx +126 -0
- package/src/components/ui/DeviceSelectionList.tsx +137 -0
- package/src/components/ui/FPSCounter.tsx +78 -0
- package/src/components/ui/FileDropzonePanel.tsx +120 -0
- package/src/components/ui/FileListPanel.tsx +285 -0
- package/src/components/ui/InputExpansionPanel.tsx +31 -0
- package/src/components/ui/MediaPlayerControls.tsx +191 -0
- package/src/components/ui/MenuContainer.tsx +71 -0
- package/src/components/ui/ParametersMenu.tsx +797 -0
- package/src/components/ui/ProjectSwitcherMenu.tsx +192 -0
- package/src/components/ui/QuickInputControls.tsx +542 -0
- package/src/components/ui/SDKMenuSystem.tsx +96 -0
- package/src/components/ui/SettingsMenu.tsx +346 -0
- package/src/components/ui/SimpleInputControls.tsx +137 -0
- package/src/index.css +68 -0
- package/src/main.tsx +10 -0
- package/src/scenes-hmr.ts +158 -0
- package/src/services/project-filesystem.ts +436 -0
- package/src/stores/scene-player/index.ts +3 -0
- package/src/stores/scene-player/input-manager.store.ts +1045 -0
- package/src/stores/scene-player/scene-session.store.ts +659 -0
- package/src/styles/globals.css +111 -0
- package/src/templates/minimal-template.js +11 -0
- package/src/utils/debounce.js +34 -0
- package/src/vite-env.d.ts +1 -0
- package/tailwind.config.js +18 -0
- package/tsconfig.app.json +27 -0
- package/tsconfig.json +27 -0
- package/tsconfig.node.json +27 -0
- package/vite.config.ts +54 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import React, { useRef, useState } from 'react';
|
|
2
|
+
import { Button } from '@heroui/react';
|
|
3
|
+
import { DocumentIcon, FolderIcon } from '@heroicons/react/24/outline';
|
|
4
|
+
import { useInputManagerStore } from '../../stores/scene-player/input-manager.store';
|
|
5
|
+
|
|
6
|
+
interface FileDropzonePanelProps {
|
|
7
|
+
fileType: 'audio' | 'video';
|
|
8
|
+
onFilesAdded?: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const FileDropzonePanel: React.FC<FileDropzonePanelProps> = ({
|
|
12
|
+
fileType,
|
|
13
|
+
onFilesAdded
|
|
14
|
+
}) => {
|
|
15
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
16
|
+
const [isDragOver, setIsDragOver] = useState(false);
|
|
17
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
18
|
+
|
|
19
|
+
const { addAudioFiles, addVideoFiles } = useInputManagerStore();
|
|
20
|
+
|
|
21
|
+
const acceptedTypes = fileType === 'audio'
|
|
22
|
+
? 'audio/*'
|
|
23
|
+
: 'video/*,image/*';
|
|
24
|
+
|
|
25
|
+
const handleFiles = async (files: FileList) => {
|
|
26
|
+
if (files.length === 0) return;
|
|
27
|
+
|
|
28
|
+
setIsLoading(true);
|
|
29
|
+
try {
|
|
30
|
+
const fileArray = Array.from(files);
|
|
31
|
+
|
|
32
|
+
if (fileType === 'audio') {
|
|
33
|
+
await addAudioFiles(fileArray);
|
|
34
|
+
} else {
|
|
35
|
+
await addVideoFiles(fileArray);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
onFilesAdded?.();
|
|
39
|
+
} catch (error) {
|
|
40
|
+
console.error(`Failed to add ${fileType} files:`, error);
|
|
41
|
+
} finally {
|
|
42
|
+
setIsLoading(false);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const handleDragOver = (e: React.DragEvent) => {
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
setIsDragOver(true);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const handleDragLeave = (e: React.DragEvent) => {
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
setIsDragOver(false);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const handleDrop = (e: React.DragEvent) => {
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
setIsDragOver(false);
|
|
59
|
+
|
|
60
|
+
const files = e.dataTransfer.files;
|
|
61
|
+
handleFiles(files);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const handleBrowseClick = () => {
|
|
65
|
+
fileInputRef.current?.click();
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
69
|
+
const files = e.target.files;
|
|
70
|
+
if (files) {
|
|
71
|
+
handleFiles(files);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className="space-y-4">
|
|
77
|
+
{/* Dropzone Area */}
|
|
78
|
+
<div
|
|
79
|
+
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
|
80
|
+
isDragOver
|
|
81
|
+
? 'border-primary-400 bg-primary-400/10'
|
|
82
|
+
: 'border-gray-600 bg-gray-800/30'
|
|
83
|
+
}`}
|
|
84
|
+
onDragOver={handleDragOver}
|
|
85
|
+
onDragLeave={handleDragLeave}
|
|
86
|
+
onDrop={handleDrop}
|
|
87
|
+
>
|
|
88
|
+
<DocumentIcon className="w-12 h-12 mx-auto mb-4 text-gray-400" />
|
|
89
|
+
<p className="text-gray-300 mb-2">
|
|
90
|
+
Drag & drop {fileType} files here
|
|
91
|
+
</p>
|
|
92
|
+
<p className="text-gray-500 text-sm">or browse</p>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
{/* Browse Button */}
|
|
96
|
+
<Button
|
|
97
|
+
variant="flat"
|
|
98
|
+
className="w-full bg-gray-700 text-white hover:bg-gray-600"
|
|
99
|
+
startContent={<FolderIcon className="w-4 h-4" />}
|
|
100
|
+
onPress={handleBrowseClick}
|
|
101
|
+
isLoading={isLoading}
|
|
102
|
+
isDisabled={isLoading}
|
|
103
|
+
>
|
|
104
|
+
{isLoading ? `Loading ${fileType} files...` : 'Browse folder'}
|
|
105
|
+
</Button>
|
|
106
|
+
|
|
107
|
+
{/* Hidden File Input */}
|
|
108
|
+
<input
|
|
109
|
+
ref={fileInputRef}
|
|
110
|
+
type="file"
|
|
111
|
+
accept={acceptedTypes}
|
|
112
|
+
multiple
|
|
113
|
+
className="hidden"
|
|
114
|
+
onChange={handleFileInputChange}
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export default FileDropzonePanel;
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import React, { useRef, useState, useEffect } from 'react';
|
|
2
|
+
import { Button } from '@heroui/react';
|
|
3
|
+
import { CheckIcon, TrashIcon } from '@heroicons/react/24/outline';
|
|
4
|
+
import { useInputManagerStore } from '../../stores/scene-player/input-manager.store';
|
|
5
|
+
import MediaPlayerControls from './MediaPlayerControls';
|
|
6
|
+
|
|
7
|
+
interface ScrollingTextProps {
|
|
8
|
+
text: string;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const ScrollingText: React.FC<ScrollingTextProps> = ({ text, className = '' }) => {
|
|
13
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
14
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
15
|
+
const [translateX, setTranslateX] = useState(0);
|
|
16
|
+
const [durationMs, setDurationMs] = useState(0);
|
|
17
|
+
const [isOverflowing, setIsOverflowing] = useState(false);
|
|
18
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const container = containerRef.current;
|
|
22
|
+
const content = contentRef.current;
|
|
23
|
+
if (!container || !content) return;
|
|
24
|
+
const overflow = content.scrollWidth > container.clientWidth + 2; // tolerance
|
|
25
|
+
setIsOverflowing(overflow);
|
|
26
|
+
if (!overflow) {
|
|
27
|
+
setTranslateX(0);
|
|
28
|
+
setDurationMs(0);
|
|
29
|
+
}
|
|
30
|
+
}, [text]);
|
|
31
|
+
|
|
32
|
+
const handleMouseEnter = () => {
|
|
33
|
+
setIsHovered(true);
|
|
34
|
+
const container = containerRef.current;
|
|
35
|
+
const content = contentRef.current;
|
|
36
|
+
if (!container || !content) return;
|
|
37
|
+
const overflow = content.scrollWidth > container.clientWidth + 2;
|
|
38
|
+
if (!overflow) return;
|
|
39
|
+
const delta = content.scrollWidth - container.clientWidth;
|
|
40
|
+
// speed ~ 60px/s
|
|
41
|
+
const ms = Math.max(300, Math.round((delta / 60) * 1000));
|
|
42
|
+
setDurationMs(ms);
|
|
43
|
+
setTranslateX(-delta);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const handleMouseLeave = () => {
|
|
47
|
+
setIsHovered(false);
|
|
48
|
+
setDurationMs(250);
|
|
49
|
+
setTranslateX(0);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div
|
|
54
|
+
ref={containerRef}
|
|
55
|
+
className={`relative overflow-hidden ${className}`}
|
|
56
|
+
onMouseEnter={handleMouseEnter}
|
|
57
|
+
onMouseLeave={handleMouseLeave}
|
|
58
|
+
title={text}
|
|
59
|
+
>
|
|
60
|
+
{/* Fade overlays */}
|
|
61
|
+
{isOverflowing && (
|
|
62
|
+
<>
|
|
63
|
+
{isHovered && (
|
|
64
|
+
<div className="pointer-events-none absolute inset-y-0 left-0 w-6 z-10" style={{
|
|
65
|
+
background: 'linear-gradient(to right, rgba(0,0,0,0.9), rgba(0,0,0,0))'
|
|
66
|
+
}} />
|
|
67
|
+
)}
|
|
68
|
+
<div className="pointer-events-none absolute inset-y-0 right-0 w-6 z-10" style={{
|
|
69
|
+
background: 'linear-gradient(to left, rgba(0,0,0,0.9), rgba(0,0,0,0))'
|
|
70
|
+
}} />
|
|
71
|
+
</>
|
|
72
|
+
)}
|
|
73
|
+
<div
|
|
74
|
+
ref={contentRef}
|
|
75
|
+
className="whitespace-nowrap inline-block"
|
|
76
|
+
style={{
|
|
77
|
+
transform: `translateX(${translateX}px)`,
|
|
78
|
+
transition: durationMs ? `transform ${durationMs}ms linear` : undefined,
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
{text}
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
interface FileListPanelProps {
|
|
88
|
+
fileType: 'audio' | 'video';
|
|
89
|
+
onBackToDropzone?: () => void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const FileListPanel: React.FC<FileListPanelProps> = ({
|
|
93
|
+
fileType,
|
|
94
|
+
onBackToDropzone
|
|
95
|
+
}) => {
|
|
96
|
+
const {
|
|
97
|
+
inputConfiguration,
|
|
98
|
+
removeAudioFile,
|
|
99
|
+
removeVideoFile,
|
|
100
|
+
playAudio,
|
|
101
|
+
playVideo
|
|
102
|
+
} = useInputManagerStore();
|
|
103
|
+
|
|
104
|
+
// Files and selection are used directly from inputConfiguration below for strict typing
|
|
105
|
+
|
|
106
|
+
const handleFileSelect = async (fileId: string) => {
|
|
107
|
+
try {
|
|
108
|
+
if (fileType === 'audio') {
|
|
109
|
+
await playAudio(fileId);
|
|
110
|
+
} else {
|
|
111
|
+
await playVideo(fileId);
|
|
112
|
+
}
|
|
113
|
+
} catch (error) {
|
|
114
|
+
console.error(`Failed to play ${fileType} file:`, error);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const handleFileRemove = (fileId: string) => {
|
|
119
|
+
if (fileType === 'audio') {
|
|
120
|
+
removeAudioFile(fileId);
|
|
121
|
+
} else {
|
|
122
|
+
removeVideoFile(fileId);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const formatDuration = (seconds: number) => {
|
|
127
|
+
const mins = Math.floor(seconds / 60);
|
|
128
|
+
const secs = Math.floor(seconds % 60);
|
|
129
|
+
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const hasNoFiles = fileType === 'audio'
|
|
133
|
+
? inputConfiguration.audio.files.length === 0
|
|
134
|
+
: inputConfiguration.video.files.length === 0;
|
|
135
|
+
|
|
136
|
+
if (hasNoFiles) {
|
|
137
|
+
return (
|
|
138
|
+
<div className="text-center py-8 text-gray-400">
|
|
139
|
+
<p>No {fileType} files loaded</p>
|
|
140
|
+
<Button
|
|
141
|
+
size="sm"
|
|
142
|
+
variant="flat"
|
|
143
|
+
className="mt-2 bg-white/10 text-white hover:bg-white/20"
|
|
144
|
+
onPress={onBackToDropzone}
|
|
145
|
+
>
|
|
146
|
+
Add Files
|
|
147
|
+
</Button>
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div className="space-y-4">
|
|
154
|
+
{/* File List */}
|
|
155
|
+
<div className="space-y-2 max-h-64 overflow-y-auto">
|
|
156
|
+
{fileType === 'audio'
|
|
157
|
+
? (
|
|
158
|
+
inputConfiguration.audio.files.map((file) => {
|
|
159
|
+
const isSelected = file.id === inputConfiguration.audio.playerState.currentTrackId;
|
|
160
|
+
const displayName = file.name;
|
|
161
|
+
// Note: SDK AudioFile doesn't have title/artist properties
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<div
|
|
165
|
+
key={file.id}
|
|
166
|
+
className={`p-3 rounded-lg border transition-colors ${
|
|
167
|
+
isSelected
|
|
168
|
+
? 'bg-white/10 border-primary-400'
|
|
169
|
+
: 'bg-transparent border-gray-600 hover:bg-white/5'
|
|
170
|
+
}`}
|
|
171
|
+
>
|
|
172
|
+
<div className="flex items-center w-full">
|
|
173
|
+
<div
|
|
174
|
+
role="button"
|
|
175
|
+
tabIndex={0}
|
|
176
|
+
className="flex items-center flex-1 justify-start px-0 py-0 h-auto min-h-0 bg-transparent hover:bg-transparent min-w-0"
|
|
177
|
+
onClick={() => handleFileSelect(file.id)}
|
|
178
|
+
onKeyDown={(e) => {
|
|
179
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
180
|
+
e.preventDefault();
|
|
181
|
+
handleFileSelect(file.id);
|
|
182
|
+
}
|
|
183
|
+
}}
|
|
184
|
+
>
|
|
185
|
+
<div className="flex-shrink-0 w-5 h-5 mr-3 flex items-center justify-center">
|
|
186
|
+
{isSelected && (
|
|
187
|
+
<CheckIcon className="w-4 h-4 text-green-400" />
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
<div className="flex-1 text-left min-w-0">
|
|
191
|
+
<ScrollingText text={displayName} className="text-white font-medium" />
|
|
192
|
+
{typeof file.duration === 'number' && (
|
|
193
|
+
<div className="text-gray-500 text-xs">
|
|
194
|
+
{formatDuration(file.duration)}
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
<Button
|
|
200
|
+
isIconOnly
|
|
201
|
+
size="sm"
|
|
202
|
+
variant="flat"
|
|
203
|
+
className="ml-2 bg-transparent hover:bg-red-500/20 text-gray-400 hover:text-red-400"
|
|
204
|
+
onPress={() => handleFileRemove(file.id)}
|
|
205
|
+
>
|
|
206
|
+
<TrashIcon className="w-4 h-4" />
|
|
207
|
+
</Button>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
})
|
|
212
|
+
) : (
|
|
213
|
+
inputConfiguration.video.files.map((file) => {
|
|
214
|
+
const isSelected = file.id === inputConfiguration.video.playerState.currentFileId;
|
|
215
|
+
const displayName = file.name;
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
<div
|
|
219
|
+
key={file.id}
|
|
220
|
+
className={`p-3 rounded-lg border transition-colors ${
|
|
221
|
+
isSelected
|
|
222
|
+
? 'bg-white/10 border-primary-400'
|
|
223
|
+
: 'bg-transparent border-gray-600 hover:bg-white/5'
|
|
224
|
+
}`}
|
|
225
|
+
>
|
|
226
|
+
<div className="flex items-center w-full">
|
|
227
|
+
<div
|
|
228
|
+
role="button"
|
|
229
|
+
tabIndex={0}
|
|
230
|
+
className="flex items-center flex-1 justify-start px-0 py-0 h-auto min-h-0 bg-transparent hover:bg-transparent min-w-0"
|
|
231
|
+
onClick={() => handleFileSelect(file.id)}
|
|
232
|
+
onKeyDown={(e) => {
|
|
233
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
234
|
+
e.preventDefault();
|
|
235
|
+
handleFileSelect(file.id);
|
|
236
|
+
}
|
|
237
|
+
}}
|
|
238
|
+
>
|
|
239
|
+
<div className="flex-shrink-0 w-5 h-5 mr-3 flex items-center justify-center">
|
|
240
|
+
{isSelected && (
|
|
241
|
+
<CheckIcon className="w-4 h-4 text-green-400" />
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
244
|
+
<div className="flex-1 text-left min-w-0">
|
|
245
|
+
<ScrollingText text={displayName} className="text-white font-medium" />
|
|
246
|
+
{typeof file.duration === 'number' && (
|
|
247
|
+
<div className="text-gray-500 text-xs">
|
|
248
|
+
{formatDuration(file.duration)}
|
|
249
|
+
</div>
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
<Button
|
|
254
|
+
isIconOnly
|
|
255
|
+
size="sm"
|
|
256
|
+
variant="flat"
|
|
257
|
+
className="ml-2 bg-transparent hover:bg-red-500/20 text-gray-400 hover:text-red-400"
|
|
258
|
+
onPress={() => handleFileRemove(file.id)}
|
|
259
|
+
>
|
|
260
|
+
<TrashIcon className="w-4 h-4" />
|
|
261
|
+
</Button>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
);
|
|
265
|
+
})
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
{/* Media Player Controls (only for audio) */}
|
|
270
|
+
{fileType === 'audio' && <MediaPlayerControls fileType={fileType} />}
|
|
271
|
+
|
|
272
|
+
{/* Add More Files Button */}
|
|
273
|
+
<Button
|
|
274
|
+
size="sm"
|
|
275
|
+
variant="flat"
|
|
276
|
+
className="w-full bg-white/10 text-white hover:bg-white/20"
|
|
277
|
+
onPress={onBackToDropzone}
|
|
278
|
+
>
|
|
279
|
+
Add More Files
|
|
280
|
+
</Button>
|
|
281
|
+
</div>
|
|
282
|
+
);
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
export default FileListPanel;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Card } from '@heroui/react';
|
|
3
|
+
|
|
4
|
+
interface InputExpansionPanelProps {
|
|
5
|
+
isOpen: boolean;
|
|
6
|
+
title: string;
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const InputExpansionPanel: React.FC<InputExpansionPanelProps> = ({
|
|
12
|
+
isOpen,
|
|
13
|
+
title,
|
|
14
|
+
children,
|
|
15
|
+
className = ""
|
|
16
|
+
}) => {
|
|
17
|
+
if (!isOpen) return null;
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div className={`absolute right-16 w-80 pointer-events-auto z-[9999] ${className}`} style={{ zIndex: 9999 }}>
|
|
21
|
+
<Card className="bg-black/90 backdrop-blur-sm border border-white/20 rounded-xl">
|
|
22
|
+
<div className="p-4">
|
|
23
|
+
<h3 className="text-white font-medium text-lg mb-4">{title}</h3>
|
|
24
|
+
{children}
|
|
25
|
+
</div>
|
|
26
|
+
</Card>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export default InputExpansionPanel;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Button, Slider } from '@heroui/react';
|
|
3
|
+
import {
|
|
4
|
+
PlayIcon,
|
|
5
|
+
PauseIcon,
|
|
6
|
+
BackwardIcon,
|
|
7
|
+
ForwardIcon,
|
|
8
|
+
SpeakerWaveIcon,
|
|
9
|
+
SpeakerXMarkIcon
|
|
10
|
+
} from '@heroicons/react/24/solid';
|
|
11
|
+
import { useInputManagerStore } from '../../stores/scene-player/input-manager.store';
|
|
12
|
+
|
|
13
|
+
interface MediaPlayerControlsProps {
|
|
14
|
+
fileType: 'audio' | 'video';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MediaPlayerControls: React.FC<MediaPlayerControlsProps> = ({ fileType }) => {
|
|
18
|
+
const {
|
|
19
|
+
inputConfiguration,
|
|
20
|
+
playAudio,
|
|
21
|
+
pauseAudio,
|
|
22
|
+
playVideo,
|
|
23
|
+
pauseVideo,
|
|
24
|
+
nextTrack,
|
|
25
|
+
previousTrack,
|
|
26
|
+
seekAudio,
|
|
27
|
+
seekVideo,
|
|
28
|
+
setAudioVolume,
|
|
29
|
+
setAudioMuted
|
|
30
|
+
} = useInputManagerStore();
|
|
31
|
+
|
|
32
|
+
const playerState = fileType === 'audio'
|
|
33
|
+
? inputConfiguration.audio.playerState
|
|
34
|
+
: inputConfiguration.video.playerState;
|
|
35
|
+
const mediaDuration = fileType === 'audio'
|
|
36
|
+
? (inputConfiguration.audio.files.find(f => f.id === inputConfiguration.audio.playerState.currentTrackId)?.duration || 0)
|
|
37
|
+
: (inputConfiguration.video.files.find(f => f.id === inputConfiguration.video.playerState.currentFileId)?.duration || 0);
|
|
38
|
+
|
|
39
|
+
const volume = inputConfiguration.audio.volume;
|
|
40
|
+
const isMuted = (inputConfiguration.audio as any).isMuted ?? (inputConfiguration.audio as any).muted;
|
|
41
|
+
|
|
42
|
+
const handlePlayPause = async () => {
|
|
43
|
+
try {
|
|
44
|
+
if (playerState.isPlaying) {
|
|
45
|
+
if (fileType === 'audio') {
|
|
46
|
+
pauseAudio();
|
|
47
|
+
} else {
|
|
48
|
+
pauseVideo();
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
if (fileType === 'audio') {
|
|
52
|
+
await playAudio();
|
|
53
|
+
} else {
|
|
54
|
+
await playVideo();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error(`Failed to ${playerState.isPlaying ? 'pause' : 'play'} ${fileType}:`, error);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const handleSeek = (time: number) => {
|
|
63
|
+
if (fileType === 'audio') {
|
|
64
|
+
seekAudio(time);
|
|
65
|
+
} else {
|
|
66
|
+
seekVideo(time);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const handleVolumeChange = (newVolume: number) => {
|
|
71
|
+
setAudioVolume(newVolume / 100);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const handleMuteToggle = () => {
|
|
75
|
+
setAudioMuted(!isMuted);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const formatTime = (seconds: number) => {
|
|
79
|
+
const mins = Math.floor(seconds / 60);
|
|
80
|
+
const secs = Math.floor(seconds % 60);
|
|
81
|
+
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const hasFiles = fileType === 'audio'
|
|
85
|
+
? inputConfiguration.audio.files.length > 0
|
|
86
|
+
: inputConfiguration.video.files.length > 0;
|
|
87
|
+
|
|
88
|
+
if (!hasFiles) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div className="space-y-3 pt-3 border-t border-gray-600">
|
|
94
|
+
{/* Progress Bar */}
|
|
95
|
+
{mediaDuration > 0 && (
|
|
96
|
+
<div className="space-y-2">
|
|
97
|
+
<Slider
|
|
98
|
+
aria-label="Playback progress"
|
|
99
|
+
step={1}
|
|
100
|
+
minValue={0}
|
|
101
|
+
maxValue={mediaDuration}
|
|
102
|
+
value={playerState.currentTime}
|
|
103
|
+
onChange={(value) => handleSeek(value as number)}
|
|
104
|
+
className="w-full"
|
|
105
|
+
size="sm"
|
|
106
|
+
color="primary"
|
|
107
|
+
/>
|
|
108
|
+
<div className="flex justify-between text-xs text-gray-400">
|
|
109
|
+
<span>{formatTime(playerState.currentTime)}</span>
|
|
110
|
+
<span>{formatTime(mediaDuration)}</span>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
|
|
115
|
+
{/* Main Controls */}
|
|
116
|
+
<div className="flex items-center justify-center space-x-2">
|
|
117
|
+
<Button
|
|
118
|
+
isIconOnly
|
|
119
|
+
size="sm"
|
|
120
|
+
variant="flat"
|
|
121
|
+
className="bg-white/10 text-white hover:bg-white/20"
|
|
122
|
+
onPress={previousTrack}
|
|
123
|
+
isDisabled={!hasFiles}
|
|
124
|
+
>
|
|
125
|
+
<BackwardIcon className="w-4 h-4" />
|
|
126
|
+
</Button>
|
|
127
|
+
|
|
128
|
+
<Button
|
|
129
|
+
isIconOnly
|
|
130
|
+
variant="flat"
|
|
131
|
+
className="bg-primary-500 text-white hover:bg-primary-600 w-10 h-10"
|
|
132
|
+
onPress={handlePlayPause}
|
|
133
|
+
isDisabled={!hasFiles}
|
|
134
|
+
>
|
|
135
|
+
{playerState.isPlaying ? (
|
|
136
|
+
<PauseIcon className="w-5 h-5" />
|
|
137
|
+
) : (
|
|
138
|
+
<PlayIcon className="w-5 h-5" />
|
|
139
|
+
)}
|
|
140
|
+
</Button>
|
|
141
|
+
|
|
142
|
+
<Button
|
|
143
|
+
isIconOnly
|
|
144
|
+
size="sm"
|
|
145
|
+
variant="flat"
|
|
146
|
+
className="bg-white/10 text-white hover:bg-white/20"
|
|
147
|
+
onPress={nextTrack}
|
|
148
|
+
isDisabled={!hasFiles}
|
|
149
|
+
>
|
|
150
|
+
<ForwardIcon className="w-4 h-4" />
|
|
151
|
+
</Button>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{/* Volume Control (Audio only) */}
|
|
155
|
+
{fileType === 'audio' && (
|
|
156
|
+
<div className="flex items-center space-x-2">
|
|
157
|
+
<Button
|
|
158
|
+
isIconOnly
|
|
159
|
+
size="sm"
|
|
160
|
+
variant="flat"
|
|
161
|
+
className="bg-transparent text-white hover:bg-white/10"
|
|
162
|
+
onPress={handleMuteToggle}
|
|
163
|
+
>
|
|
164
|
+
{isMuted ? (
|
|
165
|
+
<SpeakerXMarkIcon className="w-4 h-4" />
|
|
166
|
+
) : (
|
|
167
|
+
<SpeakerWaveIcon className="w-4 h-4" />
|
|
168
|
+
)}
|
|
169
|
+
</Button>
|
|
170
|
+
<Slider
|
|
171
|
+
aria-label="Volume"
|
|
172
|
+
step={1}
|
|
173
|
+
minValue={0}
|
|
174
|
+
maxValue={100}
|
|
175
|
+
value={isMuted ? 0 : volume * 100}
|
|
176
|
+
onChange={(val) => {
|
|
177
|
+
const numeric = Array.isArray(val) ? val[0] : val;
|
|
178
|
+
handleVolumeChange(numeric as number);
|
|
179
|
+
}}
|
|
180
|
+
className="flex-1"
|
|
181
|
+
size="sm"
|
|
182
|
+
color="primary"
|
|
183
|
+
isDisabled={isMuted}
|
|
184
|
+
/>
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
export default MediaPlayerControls;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Button, Card, CardBody } from '@heroui/react';
|
|
3
|
+
import { XMarkIcon } from '@heroicons/react/24/outline';
|
|
4
|
+
|
|
5
|
+
interface MenuContainerProps {
|
|
6
|
+
isOpen: boolean;
|
|
7
|
+
activeMenu: 'projects' | 'parameters' | 'settings' | null;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
children: React.ReactNode;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const MenuContainer: React.FC<MenuContainerProps> = ({
|
|
14
|
+
isOpen,
|
|
15
|
+
activeMenu,
|
|
16
|
+
onClose,
|
|
17
|
+
children,
|
|
18
|
+
className = '',
|
|
19
|
+
}) => {
|
|
20
|
+
if (!isOpen || !activeMenu) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const getMenuTitle = (menu: string) => {
|
|
25
|
+
switch (menu) {
|
|
26
|
+
case 'projects': return 'Projects';
|
|
27
|
+
case 'parameters': return 'Parameters';
|
|
28
|
+
case 'settings': return 'Settings';
|
|
29
|
+
default: return menu.charAt(0).toUpperCase() + menu.slice(1);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className={`absolute left-4 z-50 pointer-events-auto ${className}`} style={{ top: '72px' }}>
|
|
35
|
+
{/* Menu Panel - Using Card component like SceneInfoBar */}
|
|
36
|
+
<Card className="bg-black/20 backdrop-blur-sm border-white/10 w-[28rem]" style={{ height: 'calc(100vh - 192px)' }}>
|
|
37
|
+
<CardBody className="p-0 flex flex-col h-full">
|
|
38
|
+
{/* Header */}
|
|
39
|
+
<div className="flex items-center justify-between p-4 border-b border-white/10 flex-shrink-0">
|
|
40
|
+
<h2 className="text-white font-semibold text-lg">
|
|
41
|
+
{getMenuTitle(activeMenu)}
|
|
42
|
+
</h2>
|
|
43
|
+
<Button
|
|
44
|
+
isIconOnly
|
|
45
|
+
variant="flat"
|
|
46
|
+
size="sm"
|
|
47
|
+
className="bg-white/10 text-white hover:bg-white/20 border-white/20"
|
|
48
|
+
onPress={onClose}
|
|
49
|
+
aria-label="Close menu"
|
|
50
|
+
>
|
|
51
|
+
<XMarkIcon className="w-5 h-5" />
|
|
52
|
+
</Button>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
{/* Menu Content */}
|
|
56
|
+
<div className="flex-1 overflow-y-auto overflow-x-hidden p-4">
|
|
57
|
+
{children}
|
|
58
|
+
</div>
|
|
59
|
+
</CardBody>
|
|
60
|
+
</Card>
|
|
61
|
+
|
|
62
|
+
{/* Backdrop (for mobile) */}
|
|
63
|
+
<div
|
|
64
|
+
className="fixed inset-0 bg-black/20 backdrop-blur-sm -z-10 md:hidden"
|
|
65
|
+
onClick={onClose}
|
|
66
|
+
/>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export default MenuContainer;
|