Skip to content

Commit f6f7bfa

Browse files
committed
feat: add remote control functionality with cursor overlay and input capture
- Implemented ControlRequestButton for requesting and releasing control. - Added ControlStatusIndicator to display current control state. - Created CursorOverlay to visualize participant cursors in real-time. - Developed InputCapture to handle input events during remote control sessions. - Introduced VideoPreview component for screen sharing with start/stop functionality. - Added useRemoteControl hook to manage input capturing and cursor updates. - Implemented useScreenCapture hook for managing screen sharing state and errors. - Created useWebRTCHost hook for managing WebRTC connections and viewer interactions.
1 parent 78508e7 commit f6f7bfa

14 files changed

Lines changed: 2098 additions & 12 deletions

File tree

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
'use client';
2+
3+
import { useState, useEffect, use, useCallback } from 'react';
4+
import Link from 'next/link';
5+
import { Users, Copy, Check, LogOut, Loader2, AlertCircle, Share2, Eye } from 'lucide-react';
6+
import { VideoPreview } from '@/components/video';
7+
import { useScreenCapture } from '@/hooks/useScreenCapture';
8+
import { useWebRTCHost } from '@/hooks/useWebRTCHost';
9+
10+
interface SessionData {
11+
id: string;
12+
join_code: string;
13+
status: string;
14+
host_user_id: string;
15+
settings: {
16+
quality?: string;
17+
allowControl?: boolean;
18+
maxParticipants?: number;
19+
};
20+
created_at: string;
21+
}
22+
23+
interface ApiResponse<T> {
24+
data?: T;
25+
error?: string;
26+
}
27+
28+
export default function HostSessionPage({ params }: { params: Promise<{ id: string }> }) {
29+
const { id: sessionId } = use(params);
30+
const [session, setSession] = useState<SessionData | null>(null);
31+
const [loading, setLoading] = useState(true);
32+
const [error, setError] = useState('');
33+
const [copied, setCopied] = useState(false);
34+
35+
// Screen capture hook
36+
const {
37+
stream,
38+
captureState,
39+
error: captureError,
40+
startCapture,
41+
stopCapture,
42+
} = useScreenCapture();
43+
44+
// WebRTC host hook
45+
const {
46+
isHosting,
47+
viewerCount,
48+
error: hostingError,
49+
startHosting,
50+
stopHosting,
51+
} = useWebRTCHost({
52+
sessionId,
53+
hostId: session?.host_user_id ?? sessionId,
54+
localStream: stream,
55+
onViewerJoined: (viewerId) => {
56+
console.log('Viewer joined:', viewerId);
57+
},
58+
onViewerLeft: (viewerId) => {
59+
console.log('Viewer left:', viewerId);
60+
},
61+
});
62+
63+
// Fetch session details
64+
useEffect(() => {
65+
async function fetchSession() {
66+
try {
67+
const res = await fetch(`/api/sessions/${sessionId}`);
68+
const data = (await res.json()) as ApiResponse<SessionData>;
69+
70+
if (!res.ok) {
71+
setError(data.error ?? 'Session not found');
72+
return;
73+
}
74+
75+
if (data.data) {
76+
setSession(data.data);
77+
}
78+
} catch {
79+
setError('Failed to load session');
80+
} finally {
81+
setLoading(false);
82+
}
83+
}
84+
85+
void fetchSession();
86+
}, [sessionId]);
87+
88+
// Start hosting when stream is available
89+
useEffect(() => {
90+
if (stream && !isHosting) {
91+
startHosting();
92+
}
93+
}, [stream, isHosting, startHosting]);
94+
95+
// Stop hosting when stream ends
96+
useEffect(() => {
97+
if (!stream && isHosting) {
98+
stopHosting();
99+
}
100+
}, [stream, isHosting, stopHosting]);
101+
102+
// Copy join link to clipboard
103+
const copyJoinLink = useCallback(async () => {
104+
if (!session) return;
105+
106+
const joinUrl = `${window.location.origin}/join/${session.join_code}`;
107+
try {
108+
await navigator.clipboard.writeText(joinUrl);
109+
setCopied(true);
110+
setTimeout(() => {
111+
setCopied(false);
112+
}, 2000);
113+
} catch (err) {
114+
console.error('Failed to copy:', err);
115+
}
116+
}, [session]);
117+
118+
// Handle stop sharing
119+
const handleStopSharing = useCallback(() => {
120+
stopCapture();
121+
stopHosting();
122+
}, [stopCapture, stopHosting]);
123+
124+
if (loading) {
125+
return (
126+
<div className="flex min-h-screen items-center justify-center bg-gray-900">
127+
<div className="text-center">
128+
<Loader2 className="mx-auto h-8 w-8 animate-spin text-white" />
129+
<p className="mt-4 text-sm text-gray-400">Loading session...</p>
130+
</div>
131+
</div>
132+
);
133+
}
134+
135+
if (error || !session) {
136+
return (
137+
<div className="flex min-h-screen flex-col bg-gray-900">
138+
<header className="border-b border-gray-800 bg-gray-900">
139+
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
140+
<div className="flex h-14 items-center">
141+
<Link href="/" className="flex items-center gap-2">
142+
<div className="bg-primary-600 flex h-7 w-7 items-center justify-center rounded-lg text-xs font-bold text-white">
143+
P
144+
</div>
145+
<span className="text-lg font-bold text-white">PairUX</span>
146+
</Link>
147+
</div>
148+
</div>
149+
</header>
150+
151+
<main className="flex flex-1 items-center justify-center px-4">
152+
<div className="w-full max-w-md rounded-xl border border-gray-700 bg-gray-800 p-8 text-center">
153+
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-900/50">
154+
<AlertCircle className="h-6 w-6 text-red-400" />
155+
</div>
156+
<h1 className="mt-4 text-xl font-semibold text-white">Session Not Found</h1>
157+
<p className="mt-2 text-sm text-gray-400">{error}</p>
158+
<Link
159+
href="/"
160+
className="bg-primary-600 hover:bg-primary-700 mt-6 inline-block rounded-lg px-4 py-2 text-sm font-semibold text-white transition-colors"
161+
>
162+
Go to Homepage
163+
</Link>
164+
</div>
165+
</main>
166+
</div>
167+
);
168+
}
169+
170+
const displayError = captureError ?? hostingError;
171+
172+
return (
173+
<div className="flex min-h-screen flex-col bg-gray-900">
174+
{/* Header */}
175+
<header className="border-b border-gray-800 bg-gray-900">
176+
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
177+
<div className="flex h-14 items-center justify-between">
178+
<div className="flex items-center gap-4">
179+
<Link href="/" className="flex items-center gap-2">
180+
<div className="bg-primary-600 flex h-7 w-7 items-center justify-center rounded-lg text-xs font-bold text-white">
181+
P
182+
</div>
183+
<span className="text-lg font-bold text-white">PairUX</span>
184+
</Link>
185+
<div className="flex items-center gap-2">
186+
<span className="rounded-full bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-400">
187+
Hosting
188+
</span>
189+
{captureState !== 'active' && (
190+
<span className="text-sm text-gray-500">(View Only)</span>
191+
)}
192+
</div>
193+
</div>
194+
195+
<div className="flex items-center gap-3">
196+
{/* Viewer count */}
197+
<div className="flex items-center gap-1.5 rounded-full bg-gray-800 px-2.5 py-1 text-xs font-medium text-gray-300">
198+
<Eye className="h-3.5 w-3.5" />
199+
{viewerCount} {viewerCount === 1 ? 'viewer' : 'viewers'}
200+
</div>
201+
202+
{/* End session button */}
203+
<Link
204+
href="/"
205+
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm font-medium text-gray-300 transition-colors hover:bg-gray-700"
206+
>
207+
<LogOut className="h-4 w-4" />
208+
<span className="hidden sm:inline">End</span>
209+
</Link>
210+
</div>
211+
</div>
212+
</div>
213+
</header>
214+
215+
{/* Main content */}
216+
<div className="flex flex-1 overflow-hidden">
217+
{/* Video area */}
218+
<main className="flex flex-1 flex-col">
219+
<VideoPreview
220+
stream={stream}
221+
captureState={captureState}
222+
error={displayError}
223+
onStartCapture={() => void startCapture()}
224+
onStopCapture={handleStopSharing}
225+
className="flex-1"
226+
/>
227+
228+
{/* Info bar */}
229+
<div className="border-t border-gray-800 bg-gray-900 px-4 py-3">
230+
<div className="flex flex-wrap items-center justify-between gap-4">
231+
{/* Join code */}
232+
<div className="flex items-center gap-3">
233+
<div>
234+
<p className="text-xs text-gray-500">Join Code</p>
235+
<p className="font-mono text-lg font-bold text-white">{session.join_code}</p>
236+
</div>
237+
<button
238+
type="button"
239+
onClick={() => void copyJoinLink()}
240+
className="flex items-center gap-1.5 rounded-lg bg-gray-800 px-3 py-2 text-sm font-medium text-gray-300 transition-colors hover:bg-gray-700"
241+
>
242+
{copied ? (
243+
<>
244+
<Check className="h-4 w-4 text-green-400" />
245+
Copied!
246+
</>
247+
) : (
248+
<>
249+
<Copy className="h-4 w-4" />
250+
Copy Link
251+
</>
252+
)}
253+
</button>
254+
</div>
255+
256+
{/* Share buttons */}
257+
<div className="flex items-center gap-2">
258+
<button
259+
type="button"
260+
onClick={() => void copyJoinLink()}
261+
className="bg-primary-600 hover:bg-primary-700 flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors"
262+
>
263+
<Share2 className="h-4 w-4" />
264+
Share Session
265+
</button>
266+
</div>
267+
</div>
268+
</div>
269+
</main>
270+
271+
{/* Info sidebar */}
272+
<aside className="hidden w-72 flex-shrink-0 border-l border-gray-800 bg-gray-900 lg:block">
273+
<div className="p-4">
274+
{/* Session info */}
275+
<div className="mb-6">
276+
<h3 className="text-sm font-semibold text-white">Session Info</h3>
277+
<div className="mt-3 space-y-3">
278+
<div>
279+
<p className="text-xs text-gray-500">Status</p>
280+
<p className="text-sm text-white">
281+
{captureState === 'active' ? 'Sharing Screen' : 'Ready to Share'}
282+
</p>
283+
</div>
284+
<div>
285+
<p className="text-xs text-gray-500">Mode</p>
286+
<p className="text-sm text-white">View Only (Web)</p>
287+
</div>
288+
<div>
289+
<p className="text-xs text-gray-500">Viewers</p>
290+
<p className="text-sm text-white">{viewerCount} connected</p>
291+
</div>
292+
</div>
293+
</div>
294+
295+
{/* How to join */}
296+
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-4">
297+
<h4 className="flex items-center gap-2 text-sm font-semibold text-white">
298+
<Users className="h-4 w-4" />
299+
Invite Viewers
300+
</h4>
301+
<ol className="mt-3 space-y-2 text-sm text-gray-400">
302+
<li className="flex items-start gap-2">
303+
<span className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-gray-700 text-xs font-medium text-white">
304+
1
305+
</span>
306+
Share the join link or code
307+
</li>
308+
<li className="flex items-start gap-2">
309+
<span className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-gray-700 text-xs font-medium text-white">
310+
2
311+
</span>
312+
Viewers open the link in their browser
313+
</li>
314+
<li className="flex items-start gap-2">
315+
<span className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-gray-700 text-xs font-medium text-white">
316+
3
317+
</span>
318+
They&apos;ll see your screen instantly
319+
</li>
320+
</ol>
321+
</div>
322+
323+
{/* Limitations note */}
324+
<div className="mt-4 rounded-lg border border-yellow-900/50 bg-yellow-900/20 p-3">
325+
<p className="text-xs text-yellow-400">
326+
<strong>Note:</strong> Web hosting is view-only. For remote control features, use
327+
the{' '}
328+
<Link href="/download" className="underline hover:text-yellow-300">
329+
desktop app
330+
</Link>
331+
.
332+
</p>
333+
</div>
334+
</div>
335+
</aside>
336+
</div>
337+
</div>
338+
);
339+
}

0 commit comments

Comments
 (0)