Spaces:
Running
Running
Add ability to screen share and upload video
#3
by
shreyask
- opened
- src/App.tsx +30 -17
- src/components/CaptioningView.tsx +19 -5
- src/components/InputSourceDialog.tsx +295 -0
- src/components/VideoScrubber.tsx +217 -0
- src/components/WelcomeScreen.tsx +0 -14
- src/constants/index.ts +1 -0
- src/types/index.ts +1 -1
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
|
6 |
import type { AppState } from "./types";
|
7 |
|
8 |
export default function App() {
|
9 |
-
const [appState, setAppState] = useState<AppState>("
|
10 |
-
const [
|
|
|
11 |
const [isVideoReady, setIsVideoReady] = useState(false);
|
12 |
const videoRef = useRef<HTMLVideoElement | null>(null);
|
13 |
|
14 |
-
const
|
15 |
-
|
16 |
-
|
|
|
17 |
}, []);
|
18 |
|
19 |
const handleStart = useCallback(() => {
|
20 |
-
setAppState("
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
38 |
|
39 |
const handleCanPlay = () => {
|
40 |
setIsVideoReady(true);
|
@@ -51,23 +64,23 @@ export default function App() {
|
|
51 |
);
|
52 |
|
53 |
useEffect(() => {
|
54 |
-
if (
|
55 |
const video = videoRef.current;
|
56 |
|
57 |
video.srcObject = null;
|
58 |
video.load();
|
59 |
|
60 |
-
const cleanup = setupVideo(video,
|
61 |
return cleanup;
|
62 |
}
|
63 |
-
}, [
|
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 |
-
{
|
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 |
-
{/*
|
112 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
113 |
<PromptInput onPromptChange={handlePromptChange} />
|
114 |
</DraggableContainer>
|
115 |
|
116 |
-
{/* Draggable Live Caption - Bottom Right */}
|
117 |
-
<DraggableContainer
|
|
|
|
|
|
|
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 = "
|
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;
|