Add ability to screen share and upload video

#3
by shreyask - opened
src/App.tsx CHANGED
@@ -2,28 +2,31 @@ import { useState, useEffect, useRef, useCallback, useMemo } from "react";
2
  import LoadingScreen from "./components/LoadingScreen";
3
  import CaptioningView from "./components/CaptioningView";
4
  import WelcomeScreen from "./components/WelcomeScreen";
5
- import WebcamPermissionDialog from "./components/WebcamPermissionDialog";
6
  import type { AppState } from "./types";
7
 
8
  export default function App() {
9
- const [appState, setAppState] = useState<AppState>("requesting-permission");
10
- const [webcamStream, setWebcamStream] = useState<MediaStream | null>(null);
 
11
  const [isVideoReady, setIsVideoReady] = useState(false);
12
  const videoRef = useRef<HTMLVideoElement | null>(null);
13
 
14
- const handlePermissionGranted = useCallback((stream: MediaStream) => {
15
- setWebcamStream(stream);
16
- setAppState("welcome");
 
17
  }, []);
18
 
19
  const handleStart = useCallback(() => {
20
- setAppState("loading");
21
  }, []);
22
 
23
  const handleLoadingComplete = useCallback(() => {
24
  setAppState("captioning");
25
  }, []);
26
 
 
27
  const playVideo = useCallback(async (video: HTMLVideoElement) => {
28
  try {
29
  await video.play();
@@ -34,7 +37,17 @@ export default function App() {
34
 
35
  const setupVideo = useCallback(
36
  (video: HTMLVideoElement, stream: MediaStream) => {
37
- video.srcObject = stream;
 
 
 
 
 
 
 
 
 
 
38
 
39
  const handleCanPlay = () => {
40
  setIsVideoReady(true);
@@ -51,23 +64,23 @@ export default function App() {
51
  );
52
 
53
  useEffect(() => {
54
- if (webcamStream && videoRef.current) {
55
  const video = videoRef.current;
56
 
57
  video.srcObject = null;
58
  video.load();
59
 
60
- const cleanup = setupVideo(video, webcamStream);
61
  return cleanup;
62
  }
63
- }, [webcamStream, setupVideo]);
64
 
65
  const videoBlurState = useMemo(() => {
66
  switch (appState) {
67
- case "requesting-permission":
68
- return "blur(20px) brightness(0.2) saturate(0.5)";
69
  case "welcome":
70
  return "blur(12px) brightness(0.3) saturate(0.7)";
 
 
71
  case "loading":
72
  return "blur(8px) brightness(0.4) saturate(0.8)";
73
  case "captioning":
@@ -81,7 +94,7 @@ export default function App() {
81
  <div className="App relative h-screen overflow-hidden">
82
  <div className="absolute inset-0 bg-gray-900" />
83
 
84
- {webcamStream && (
85
  <video
86
  ref={videoRef}
87
  autoPlay
@@ -97,13 +110,13 @@ export default function App() {
97
 
98
  {appState !== "captioning" && <div className="absolute inset-0 bg-gray-900/80 backdrop-blur-sm" />}
99
 
100
- {appState === "requesting-permission" && <WebcamPermissionDialog onPermissionGranted={handlePermissionGranted} />}
101
-
102
  {appState === "welcome" && <WelcomeScreen onStart={handleStart} />}
103
 
 
 
104
  {appState === "loading" && <LoadingScreen onComplete={handleLoadingComplete} />}
105
 
106
- {appState === "captioning" && <CaptioningView videoRef={videoRef} />}
107
  </div>
108
  );
109
  }
 
2
  import LoadingScreen from "./components/LoadingScreen";
3
  import CaptioningView from "./components/CaptioningView";
4
  import WelcomeScreen from "./components/WelcomeScreen";
5
+ import InputSourceDialog from "./components/InputSourceDialog";
6
  import type { AppState } from "./types";
7
 
8
  export default function App() {
9
+ const [appState, setAppState] = useState<AppState>("welcome");
10
+ const [mediaStream, setMediaStream] = useState<MediaStream | null>(null);
11
+ const [sourceType, setSourceType] = useState<'webcam' | 'screen' | 'file' | null>(null);
12
  const [isVideoReady, setIsVideoReady] = useState(false);
13
  const videoRef = useRef<HTMLVideoElement | null>(null);
14
 
15
+ const handleSourceSelected = useCallback((stream: MediaStream, type: 'webcam' | 'screen' | 'file') => {
16
+ setMediaStream(stream);
17
+ setSourceType(type);
18
+ setAppState("loading");
19
  }, []);
20
 
21
  const handleStart = useCallback(() => {
22
+ setAppState("source-selection");
23
  }, []);
24
 
25
  const handleLoadingComplete = useCallback(() => {
26
  setAppState("captioning");
27
  }, []);
28
 
29
+
30
  const playVideo = useCallback(async (video: HTMLVideoElement) => {
31
  try {
32
  await video.play();
 
37
 
38
  const setupVideo = useCallback(
39
  (video: HTMLVideoElement, stream: MediaStream) => {
40
+ // Check if this is a video file (mock stream with videoFileUrl)
41
+ const videoFileUrl = (stream as any).videoFileUrl;
42
+
43
+ if (videoFileUrl) {
44
+ // For video files, use the file URL directly
45
+ video.src = videoFileUrl;
46
+ video.srcObject = null;
47
+ } else {
48
+ // For webcam/screen, use the stream
49
+ video.srcObject = stream;
50
+ }
51
 
52
  const handleCanPlay = () => {
53
  setIsVideoReady(true);
 
64
  );
65
 
66
  useEffect(() => {
67
+ if (mediaStream && videoRef.current) {
68
  const video = videoRef.current;
69
 
70
  video.srcObject = null;
71
  video.load();
72
 
73
+ const cleanup = setupVideo(video, mediaStream);
74
  return cleanup;
75
  }
76
+ }, [mediaStream, setupVideo]);
77
 
78
  const videoBlurState = useMemo(() => {
79
  switch (appState) {
 
 
80
  case "welcome":
81
  return "blur(12px) brightness(0.3) saturate(0.7)";
82
+ case "source-selection":
83
+ return "blur(20px) brightness(0.2) saturate(0.5)";
84
  case "loading":
85
  return "blur(8px) brightness(0.4) saturate(0.8)";
86
  case "captioning":
 
94
  <div className="App relative h-screen overflow-hidden">
95
  <div className="absolute inset-0 bg-gray-900" />
96
 
97
+ {mediaStream && (
98
  <video
99
  ref={videoRef}
100
  autoPlay
 
110
 
111
  {appState !== "captioning" && <div className="absolute inset-0 bg-gray-900/80 backdrop-blur-sm" />}
112
 
 
 
113
  {appState === "welcome" && <WelcomeScreen onStart={handleStart} />}
114
 
115
+ {appState === "source-selection" && <InputSourceDialog onSourceSelected={handleSourceSelected} />}
116
+
117
  {appState === "loading" && <LoadingScreen onComplete={handleLoadingComplete} />}
118
 
119
+ {appState === "captioning" && <CaptioningView videoRef={videoRef} sourceType={sourceType} />}
120
  </div>
121
  );
122
  }
src/components/CaptioningView.tsx CHANGED
@@ -1,5 +1,6 @@
1
  import { useState, useRef, useEffect, useCallback } from "react";
2
  import WebcamCapture from "./WebcamCapture";
 
3
  import DraggableContainer from "./DraggableContainer";
4
  import PromptInput from "./PromptInput";
5
  import LiveCaption from "./LiveCaption";
@@ -8,6 +9,7 @@ import { PROMPTS, TIMING } from "../constants";
8
 
9
  interface CaptioningViewProps {
10
  videoRef: React.RefObject<HTMLVideoElement | null>;
 
11
  }
12
 
13
  function useCaptioningLoop(
@@ -67,7 +69,7 @@ function useCaptioningLoop(
67
  }, [isRunning, isLoaded, runInference, promptRef, videoRef]);
68
  }
69
 
70
- export default function CaptioningView({ videoRef }: CaptioningViewProps) {
71
  const [caption, setCaption] = useState<string>("");
72
  const [isLoopRunning, setIsLoopRunning] = useState<boolean>(true);
73
  const [currentPrompt, setCurrentPrompt] = useState<string>(PROMPTS.default);
@@ -108,13 +110,25 @@ export default function CaptioningView({ videoRef }: CaptioningViewProps) {
108
  <div className="relative w-full h-full">
109
  <WebcamCapture isRunning={isLoopRunning} onToggleRunning={handleToggleLoop} error={error} />
110
 
111
- {/* Draggable Prompt Input - Bottom Left */}
112
- <DraggableContainer initialPosition="bottom-left">
 
 
 
 
 
 
 
 
 
113
  <PromptInput onPromptChange={handlePromptChange} />
114
  </DraggableContainer>
115
 
116
- {/* Draggable Live Caption - Bottom Right */}
117
- <DraggableContainer initialPosition="bottom-right">
 
 
 
118
  <LiveCaption caption={caption} isRunning={isLoopRunning} error={error} />
119
  </DraggableContainer>
120
  </div>
 
1
  import { useState, useRef, useEffect, useCallback } from "react";
2
  import WebcamCapture from "./WebcamCapture";
3
+ import VideoScrubber from "./VideoScrubber";
4
  import DraggableContainer from "./DraggableContainer";
5
  import PromptInput from "./PromptInput";
6
  import LiveCaption from "./LiveCaption";
 
9
 
10
  interface CaptioningViewProps {
11
  videoRef: React.RefObject<HTMLVideoElement | null>;
12
+ sourceType?: 'webcam' | 'screen' | 'file' | null;
13
  }
14
 
15
  function useCaptioningLoop(
 
69
  }, [isRunning, isLoaded, runInference, promptRef, videoRef]);
70
  }
71
 
72
+ export default function CaptioningView({ videoRef, sourceType }: CaptioningViewProps) {
73
  const [caption, setCaption] = useState<string>("");
74
  const [isLoopRunning, setIsLoopRunning] = useState<boolean>(true);
75
  const [currentPrompt, setCurrentPrompt] = useState<string>(PROMPTS.default);
 
110
  <div className="relative w-full h-full">
111
  <WebcamCapture isRunning={isLoopRunning} onToggleRunning={handleToggleLoop} error={error} />
112
 
113
+ {/* Video Scrubber - Only show for video files */}
114
+ <VideoScrubber
115
+ videoRef={videoRef}
116
+ isVisible={sourceType === 'file'}
117
+ />
118
+
119
+ {/* Draggable Prompt Input - Bottom Left (above scrubber) */}
120
+ <DraggableContainer
121
+ initialPosition={sourceType === 'file' ? { x: 20, y: window.innerHeight - 200 } : "bottom-left"}
122
+ className="z-[150]"
123
+ >
124
  <PromptInput onPromptChange={handlePromptChange} />
125
  </DraggableContainer>
126
 
127
+ {/* Draggable Live Caption - Bottom Right (above scrubber) */}
128
+ <DraggableContainer
129
+ initialPosition={sourceType === 'file' ? { x: window.innerWidth - 170, y: window.innerHeight - 200 } : "bottom-right"}
130
+ className="z-[150]"
131
+ >
132
  <LiveCaption caption={caption} isRunning={isLoopRunning} error={error} />
133
  </DraggableContainer>
134
  </div>
src/components/InputSourceDialog.tsx ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback, useMemo } from "react";
2
+ import GlassContainer from "./GlassContainer";
3
+ import GlassButton from "./GlassButton";
4
+ import { GLASS_EFFECTS } from "../constants";
5
+
6
+ const ERROR_TYPES = {
7
+ HTTPS: "https",
8
+ NOT_SUPPORTED: "not-supported",
9
+ PERMISSION: "permission",
10
+ GENERAL: "general",
11
+ } as const;
12
+
13
+ const VIDEO_CONSTRAINTS = {
14
+ video: {
15
+ width: { ideal: 1920, max: 1920 },
16
+ height: { ideal: 1080, max: 1080 },
17
+ facingMode: "user",
18
+ },
19
+ };
20
+
21
+ const SCREEN_CONSTRAINTS = {
22
+ video: {
23
+ width: { ideal: 1920, max: 1920 },
24
+ height: { ideal: 1080, max: 1080 },
25
+ },
26
+ audio: false,
27
+ };
28
+
29
+ interface ErrorInfo {
30
+ type: (typeof ERROR_TYPES)[keyof typeof ERROR_TYPES];
31
+ message: string;
32
+ }
33
+
34
+ interface InputSourceDialogProps {
35
+ onSourceSelected: (stream: MediaStream, sourceType: 'webcam' | 'screen' | 'file') => void;
36
+ }
37
+
38
+ type InputSource = 'webcam' | 'screen' | 'file';
39
+
40
+ export default function InputSourceDialog({ onSourceSelected }: InputSourceDialogProps) {
41
+ const [selectedSource, setSelectedSource] = useState<InputSource | null>(null);
42
+ const [isRequesting, setIsRequesting] = useState(false);
43
+ const [error, setError] = useState<ErrorInfo | null>(null);
44
+
45
+ const getErrorInfo = (err: unknown): ErrorInfo => {
46
+ if (!navigator.mediaDevices) {
47
+ return {
48
+ type: ERROR_TYPES.HTTPS,
49
+ message: "Media access requires a secure connection (HTTPS)",
50
+ };
51
+ }
52
+
53
+ if (err instanceof DOMException) {
54
+ switch (err.name) {
55
+ case "NotAllowedError":
56
+ return {
57
+ type: ERROR_TYPES.PERMISSION,
58
+ message: "Media access denied",
59
+ };
60
+ case "NotFoundError":
61
+ return {
62
+ type: ERROR_TYPES.GENERAL,
63
+ message: "No camera found",
64
+ };
65
+ case "NotReadableError":
66
+ return {
67
+ type: ERROR_TYPES.GENERAL,
68
+ message: "Camera is in use by another application",
69
+ };
70
+ case "OverconstrainedError":
71
+ return {
72
+ type: ERROR_TYPES.GENERAL,
73
+ message: "Camera doesn't meet requirements",
74
+ };
75
+ case "SecurityError":
76
+ return {
77
+ type: ERROR_TYPES.HTTPS,
78
+ message: "Security error accessing media",
79
+ };
80
+ default:
81
+ return {
82
+ type: ERROR_TYPES.GENERAL,
83
+ message: `Media error: ${err.name}`,
84
+ };
85
+ }
86
+ }
87
+
88
+ return {
89
+ type: ERROR_TYPES.GENERAL,
90
+ message: "Failed to access media",
91
+ };
92
+ };
93
+
94
+ const requestWebcamAccess = useCallback(async () => {
95
+ setIsRequesting(true);
96
+ setError(null);
97
+
98
+ try {
99
+ if (!navigator.mediaDevices?.getUserMedia) {
100
+ throw new Error("NOT_SUPPORTED");
101
+ }
102
+
103
+ const stream = await navigator.mediaDevices.getUserMedia(VIDEO_CONSTRAINTS);
104
+ onSourceSelected(stream, 'webcam');
105
+ } catch (err) {
106
+ const errorInfo = getErrorInfo(err);
107
+ setError(errorInfo);
108
+ console.error("Error accessing webcam:", err, errorInfo);
109
+ } finally {
110
+ setIsRequesting(false);
111
+ }
112
+ }, [onSourceSelected]);
113
+
114
+ const requestScreenAccess = useCallback(async () => {
115
+ setIsRequesting(true);
116
+ setError(null);
117
+
118
+ try {
119
+ if (!navigator.mediaDevices?.getDisplayMedia) {
120
+ throw new Error("Screen sharing not supported");
121
+ }
122
+
123
+ const stream = await navigator.mediaDevices.getDisplayMedia(SCREEN_CONSTRAINTS);
124
+ onSourceSelected(stream, 'screen');
125
+ } catch (err) {
126
+ const errorInfo = getErrorInfo(err);
127
+ setError(errorInfo);
128
+ console.error("Error accessing screen:", err, errorInfo);
129
+ } finally {
130
+ setIsRequesting(false);
131
+ }
132
+ }, [onSourceSelected]);
133
+
134
+ const handleFileSelect = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
135
+ const file = event.target.files?.[0];
136
+ if (!file) return;
137
+
138
+ // Create a video element that will be used directly instead of canvas stream
139
+ const videoUrl = URL.createObjectURL(file);
140
+
141
+ // Create a mock stream that signals this is a file source
142
+ const canvas = document.createElement('canvas');
143
+ canvas.width = 1;
144
+ canvas.height = 1;
145
+ const mockStream = canvas.captureStream(1);
146
+
147
+ // Store the video file URL on the stream for later use
148
+ (mockStream as any).videoFileUrl = videoUrl;
149
+
150
+ onSourceSelected(mockStream, 'file');
151
+ }, [onSourceSelected]);
152
+
153
+ const renderIcon = (source: InputSource) => {
154
+ const iconClass = "w-8 h-8";
155
+
156
+ switch (source) {
157
+ case 'webcam':
158
+ return (
159
+ <svg className={`${iconClass} text-blue-400`} fill="currentColor" viewBox="0 0 20 20">
160
+ <path fillRule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clipRule="evenodd" />
161
+ </svg>
162
+ );
163
+ case 'screen':
164
+ return (
165
+ <svg className={`${iconClass} text-green-400`} fill="currentColor" viewBox="0 0 20 20">
166
+ <path fillRule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 011 1v8a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm2 1v6h10V5H5z" clipRule="evenodd" />
167
+ <path d="M10 15a1 1 0 011-1h2a1 1 0 110 2h-2a1 1 0 01-1-1zM7 14a1 1 0 100 2h2a1 1 0 100-2H7z" />
168
+ </svg>
169
+ );
170
+ case 'file':
171
+ return (
172
+ <svg className={`${iconClass} text-purple-400`} fill="currentColor" viewBox="0 0 20 20">
173
+ <path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 011 1v1a1 1 0 01-1 1H4a1 1 0 01-1-1v-1zM3 7a1 1 0 011-1h12a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1V7zM4 9h12v2H4V9z" clipRule="evenodd" />
174
+ </svg>
175
+ );
176
+ }
177
+ };
178
+
179
+ if (selectedSource && isRequesting) {
180
+ return (
181
+ <div className="absolute inset-0 text-white flex items-center justify-center p-8">
182
+ <GlassContainer className="rounded-3xl shadow-2xl">
183
+ <div className="p-8 text-center space-y-6">
184
+ <div className="animate-spin rounded-full h-16 w-16 border-4 border-blue-500 border-t-transparent mx-auto" />
185
+ <h2 className="text-2xl font-bold text-gray-100">
186
+ {selectedSource === 'webcam' ? 'Requesting Camera Access' :
187
+ selectedSource === 'screen' ? 'Requesting Screen Access' :
188
+ 'Loading Video File'}
189
+ </h2>
190
+ <p className="text-gray-400">Please allow access in your browser to continue...</p>
191
+ </div>
192
+ </GlassContainer>
193
+ </div>
194
+ );
195
+ }
196
+
197
+ return (
198
+ <div className="absolute inset-0 text-white flex items-center justify-center p-8">
199
+ <div className="max-w-2xl w-full space-y-6">
200
+ <GlassContainer className="rounded-3xl shadow-2xl">
201
+ <div className="p-8 text-center space-y-6">
202
+ <h2 className="text-3xl font-bold text-gray-100">Choose Input Source</h2>
203
+ <p className="text-gray-400">Select how you want to provide video for captioning</p>
204
+
205
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-8">
206
+ {/* Webcam Option */}
207
+ <GlassContainer
208
+ className="rounded-2xl p-6 cursor-pointer hover:scale-105 transition-transform duration-200"
209
+ bgColor={GLASS_EFFECTS.COLORS.DEFAULT_BG}
210
+ onClick={() => {
211
+ setSelectedSource('webcam');
212
+ requestWebcamAccess();
213
+ }}
214
+ >
215
+ <div className="flex flex-col items-center space-y-3">
216
+ <div className="w-16 h-16 rounded-full bg-blue-500/20 flex items-center justify-center">
217
+ {renderIcon('webcam')}
218
+ </div>
219
+ <h3 className="text-lg font-semibold text-gray-200">Webcam</h3>
220
+ <p className="text-sm text-gray-400 text-center">Use your camera for live captioning</p>
221
+ </div>
222
+ </GlassContainer>
223
+
224
+ {/* Screen Recording Option */}
225
+ <GlassContainer
226
+ className="rounded-2xl p-6 cursor-pointer hover:scale-105 transition-transform duration-200"
227
+ bgColor={GLASS_EFFECTS.COLORS.DEFAULT_BG}
228
+ onClick={() => {
229
+ setSelectedSource('screen');
230
+ requestScreenAccess();
231
+ }}
232
+ >
233
+ <div className="flex flex-col items-center space-y-3">
234
+ <div className="w-16 h-16 rounded-full bg-green-500/20 flex items-center justify-center">
235
+ {renderIcon('screen')}
236
+ </div>
237
+ <h3 className="text-lg font-semibold text-gray-200">Screen</h3>
238
+ <p className="text-sm text-gray-400 text-center">Record and caption your screen</p>
239
+ </div>
240
+ </GlassContainer>
241
+
242
+ {/* Video File Option */}
243
+ <GlassContainer
244
+ className="rounded-2xl p-6 cursor-pointer hover:scale-105 transition-transform duration-200"
245
+ bgColor={GLASS_EFFECTS.COLORS.DEFAULT_BG}
246
+ >
247
+ <label className="flex flex-col items-center space-y-3 cursor-pointer">
248
+ <div className="w-16 h-16 rounded-full bg-purple-500/20 flex items-center justify-center">
249
+ {renderIcon('file')}
250
+ </div>
251
+ <h3 className="text-lg font-semibold text-gray-200">Video File</h3>
252
+ <p className="text-sm text-gray-400 text-center">Upload a video file to caption</p>
253
+ <input
254
+ type="file"
255
+ accept="video/*"
256
+ className="hidden"
257
+ onChange={handleFileSelect}
258
+ />
259
+ </label>
260
+ </GlassContainer>
261
+ </div>
262
+ </div>
263
+ </GlassContainer>
264
+
265
+ {/* Error Display */}
266
+ {error && (
267
+ <GlassContainer
268
+ className="rounded-2xl shadow-2xl"
269
+ bgColor={GLASS_EFFECTS.COLORS.ERROR_BG}
270
+ >
271
+ <div className="p-6 text-center">
272
+ <div className="w-16 h-16 rounded-full bg-red-500/20 flex items-center justify-center mx-auto mb-4">
273
+ <svg className="w-8 h-8 text-red-400" fill="currentColor" viewBox="0 0 20 20">
274
+ <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
275
+ </svg>
276
+ </div>
277
+ <h3 className="text-xl font-bold text-gray-100 mb-2">Access Failed</h3>
278
+ <p className="text-red-400 mb-4">{error.message}</p>
279
+
280
+ <GlassButton
281
+ onClick={() => {
282
+ setError(null);
283
+ setSelectedSource(null);
284
+ }}
285
+ className="px-6 py-3"
286
+ >
287
+ Try Different Source
288
+ </GlassButton>
289
+ </div>
290
+ </GlassContainer>
291
+ )}
292
+ </div>
293
+ </div>
294
+ );
295
+ }
src/components/VideoScrubber.tsx ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+ import GlassContainer from "./GlassContainer";
3
+ import GlassButton from "./GlassButton";
4
+ import { GLASS_EFFECTS } from "../constants";
5
+
6
+ interface VideoScrubberProps {
7
+ videoRef: React.RefObject<HTMLVideoElement | null>;
8
+ isVisible: boolean;
9
+ }
10
+
11
+ export default function VideoScrubber({ videoRef, isVisible }: VideoScrubberProps) {
12
+ const [currentTime, setCurrentTime] = useState(0);
13
+ const [duration, setDuration] = useState(0);
14
+ const [isDragging, setIsDragging] = useState(false);
15
+ const [isHovered, setIsHovered] = useState(false);
16
+ const [isPlaying, setIsPlaying] = useState(false);
17
+ const scrubberRef = useRef<HTMLInputElement>(null);
18
+
19
+ const formatTime = useCallback((seconds: number) => {
20
+ if (!isFinite(seconds) || isNaN(seconds)) {
21
+ return "0:00";
22
+ }
23
+ const mins = Math.floor(seconds / 60);
24
+ const secs = Math.floor(seconds % 60);
25
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
26
+ }, []);
27
+
28
+ const updateProgress = useCallback(() => {
29
+ const video = videoRef.current;
30
+ if (video && !isDragging) {
31
+ const time = isFinite(video.currentTime) ? video.currentTime : 0;
32
+ const dur = isFinite(video.duration) ? video.duration : 0;
33
+ setCurrentTime(time);
34
+ setDuration(dur);
35
+ setIsPlaying(!video.paused);
36
+ }
37
+ }, [videoRef, isDragging]);
38
+
39
+ const togglePlayPause = useCallback(() => {
40
+ const video = videoRef.current;
41
+ if (video) {
42
+ if (video.paused) {
43
+ video.play();
44
+ } else {
45
+ video.pause();
46
+ }
47
+ }
48
+ }, [videoRef]);
49
+
50
+ const handleSeek = useCallback((newTime: number) => {
51
+ const video = videoRef.current;
52
+ if (video && !isNaN(newTime)) {
53
+ video.currentTime = newTime;
54
+ setCurrentTime(newTime);
55
+ }
56
+ }, [videoRef]);
57
+
58
+ const handleScrubberChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
59
+ const newTime = parseFloat(event.target.value);
60
+ handleSeek(newTime);
61
+ }, [handleSeek]);
62
+
63
+ const handleMouseDown = useCallback(() => {
64
+ setIsDragging(true);
65
+ }, []);
66
+
67
+ const handleMouseUp = useCallback(() => {
68
+ setIsDragging(false);
69
+ }, []);
70
+
71
+ useEffect(() => {
72
+ const video = videoRef.current;
73
+ if (!video) return;
74
+
75
+ const handleLoadedMetadata = () => {
76
+ const dur = isFinite(video.duration) ? video.duration : 0;
77
+ const time = isFinite(video.currentTime) ? video.currentTime : 0;
78
+ setDuration(dur);
79
+ setCurrentTime(time);
80
+ };
81
+
82
+ const handleTimeUpdate = () => {
83
+ updateProgress();
84
+ };
85
+
86
+ const handlePlay = () => setIsPlaying(true);
87
+ const handlePause = () => setIsPlaying(false);
88
+
89
+ video.addEventListener('loadedmetadata', handleLoadedMetadata);
90
+ video.addEventListener('timeupdate', handleTimeUpdate);
91
+ video.addEventListener('play', handlePlay);
92
+ video.addEventListener('pause', handlePause);
93
+
94
+ // Update immediately if metadata is already loaded
95
+ if (video.readyState >= 1) {
96
+ handleLoadedMetadata();
97
+ }
98
+
99
+ return () => {
100
+ video.removeEventListener('loadedmetadata', handleLoadedMetadata);
101
+ video.removeEventListener('timeupdate', handleTimeUpdate);
102
+ video.removeEventListener('play', handlePlay);
103
+ video.removeEventListener('pause', handlePause);
104
+ };
105
+ }, [videoRef, updateProgress]);
106
+
107
+ useEffect(() => {
108
+ if (isDragging) {
109
+ document.addEventListener('mouseup', handleMouseUp);
110
+ return () => {
111
+ document.removeEventListener('mouseup', handleMouseUp);
112
+ };
113
+ }
114
+ }, [isDragging, handleMouseUp]);
115
+
116
+ if (!isVisible) {
117
+ return null;
118
+ }
119
+
120
+ const progressPercentage = duration > 0 && isFinite(duration) && isFinite(currentTime)
121
+ ? Math.min((currentTime / duration) * 100, 100)
122
+ : 0;
123
+
124
+ return (
125
+ <div
126
+ className={`absolute bottom-4 left-4 right-4 z-[200] transition-opacity duration-300 ${
127
+ isHovered || isDragging ? 'opacity-100' : 'opacity-90'
128
+ }`}
129
+ onMouseEnter={() => setIsHovered(true)}
130
+ onMouseLeave={() => setIsHovered(false)}
131
+ style={{ pointerEvents: 'auto' }}
132
+ >
133
+ <GlassContainer
134
+ bgColor={GLASS_EFFECTS.COLORS.DEFAULT_BG}
135
+ className="rounded-lg px-4 py-3 shadow-lg"
136
+ >
137
+ <div className="flex items-center space-x-4">
138
+ {/* Play/Pause Button */}
139
+ <GlassButton
140
+ onClick={togglePlayPause}
141
+ className="w-10 h-10 rounded-full flex items-center justify-center p-0"
142
+ aria-label={isPlaying ? "Pause video" : "Play video"}
143
+ >
144
+ {isPlaying ? (
145
+ <svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
146
+ <path fillRule="evenodd" d="M6 4a1 1 0 011 1v10a1 1 0 11-2 0V5a1 1 0 011-1zM14 4a1 1 0 011 1v10a1 1 0 11-2 0V5a1 1 0 011-1z" clipRule="evenodd" />
147
+ </svg>
148
+ ) : (
149
+ <svg className="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 20 20">
150
+ <path fillRule="evenodd" d="M6.267 3.455a.5.5 0 01.531-.024L15.5 8.5a.5.5 0 010 .872l-8.702 5.069a.5.5 0 01-.765-.436V3.455z" clipRule="evenodd" />
151
+ </svg>
152
+ )}
153
+ </GlassButton>
154
+
155
+ {/* Current Time */}
156
+ <div className="text-white text-sm font-mono min-w-[3.5rem]">
157
+ {formatTime(currentTime)}
158
+ </div>
159
+
160
+ {/* Timeline Scrubber */}
161
+ <div className="flex-1 relative px-2">
162
+ <input
163
+ ref={scrubberRef}
164
+ type="range"
165
+ min={0}
166
+ max={duration || 100}
167
+ step={0.1}
168
+ value={currentTime}
169
+ onChange={handleScrubberChange}
170
+ onMouseDown={handleMouseDown}
171
+ className="w-full h-1.5 bg-transparent rounded-lg appearance-none cursor-pointer
172
+ [&::-webkit-slider-track]:bg-gray-600/50
173
+ [&::-webkit-slider-track]:rounded-lg
174
+ [&::-webkit-slider-track]:h-1.5
175
+ [&::-webkit-slider-thumb]:appearance-none
176
+ [&::-webkit-slider-thumb]:w-4
177
+ [&::-webkit-slider-thumb]:h-4
178
+ [&::-webkit-slider-thumb]:rounded-full
179
+ [&::-webkit-slider-thumb]:bg-white
180
+ [&::-webkit-slider-thumb]:shadow-lg
181
+ [&::-webkit-slider-thumb]:cursor-pointer
182
+ [&::-webkit-slider-thumb]:border-2
183
+ [&::-webkit-slider-thumb]:border-blue-500
184
+ [&::-webkit-slider-thumb]:transition-transform
185
+ [&::-moz-range-track]:bg-gray-600/50
186
+ [&::-moz-range-track]:rounded-lg
187
+ [&::-moz-range-track]:h-1.5
188
+ [&::-moz-range-track]:border-0
189
+ [&::-moz-range-thumb]:w-4
190
+ [&::-moz-range-thumb]:h-4
191
+ [&::-moz-range-thumb]:rounded-full
192
+ [&::-moz-range-thumb]:bg-white
193
+ [&::-moz-range-thumb]:border-2
194
+ [&::-moz-range-thumb]:border-blue-500
195
+ [&::-moz-range-thumb]:cursor-pointer
196
+ [&::-moz-range-thumb]:transition-transform
197
+ hover:[&::-webkit-slider-thumb]:scale-110
198
+ hover:[&::-moz-range-thumb]:scale-110"
199
+ style={{
200
+ background: `linear-gradient(to right,
201
+ #3b82f6 0%,
202
+ #3b82f6 ${progressPercentage}%,
203
+ rgba(75, 85, 99, 0.3) ${progressPercentage}%,
204
+ rgba(75, 85, 99, 0.3) 100%)`
205
+ }}
206
+ />
207
+ </div>
208
+
209
+ {/* Duration */}
210
+ <div className="text-white text-sm font-mono min-w-[3.5rem] text-right">
211
+ {formatTime(duration)}
212
+ </div>
213
+ </div>
214
+ </GlassContainer>
215
+ </div>
216
+ );
217
+ }
src/components/WelcomeScreen.tsx CHANGED
@@ -33,20 +33,6 @@ export default function WelcomeScreen({ onStart }: WelcomeScreenProps) {
33
  </div>
34
  </GlassContainer>
35
 
36
- {/* Webcam Status Card */}
37
- <GlassContainer
38
- bgColor={GLASS_EFFECTS.COLORS.SUCCESS_BG}
39
- className="rounded-2xl shadow-2xl hover:scale-105 transition-transform duration-200"
40
- role="status"
41
- aria-label="Camera status"
42
- >
43
- <div className="p-4">
44
- <div className="flex items-center justify-center space-x-2">
45
- <div className="w-3 h-3 rounded-full bg-green-500 animate-pulse"></div>
46
- <p className="text-green-400 font-medium">Camera ready</p>
47
- </div>
48
- </div>
49
- </GlassContainer>
50
 
51
  {/* How It Works Card */}
52
  <GlassContainer
 
33
  </div>
34
  </GlassContainer>
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
  {/* How It Works Card */}
38
  <GlassContainer
src/constants/index.ts CHANGED
@@ -16,6 +16,7 @@ export const LAYOUT = {
16
  MARGINS: {
17
  DEFAULT: 20,
18
  BOTTOM: 20,
 
19
  },
20
  DIMENSIONS: {
21
  PROMPT_WIDTH: 420,
 
16
  MARGINS: {
17
  DEFAULT: 20,
18
  BOTTOM: 20,
19
+ BOTTOM_WITH_SCRUBBER: 100,
20
  },
21
  DIMENSIONS: {
22
  PROMPT_WIDTH: 420,
src/types/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type AppState = "requesting-permission" | "welcome" | "loading" | "captioning";
2
 
3
  export interface GlassEffectProps {
4
  baseFrequency?: number;
 
1
+ export type AppState = "welcome" | "source-selection" | "loading" | "captioning";
2
 
3
  export interface GlassEffectProps {
4
  baseFrequency?: number;