@sansavision/create-pulse 0.4.2 → 0.4.4

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.
@@ -0,0 +1,740 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useRef, useCallback } from "react";
4
+ import { useSession } from "@/lib/auth-client";
5
+ import { connectWithAuth } from "@/lib/pulse";
6
+ import {
7
+ Video,
8
+ VideoOff,
9
+ Mic,
10
+ MicOff,
11
+ PhoneCall,
12
+ PhoneOff,
13
+ Users,
14
+ Wifi,
15
+ WifiOff,
16
+ Shield,
17
+ Loader2,
18
+ Monitor,
19
+ Copy,
20
+ Check,
21
+ Maximize,
22
+ Minimize,
23
+ } from "lucide-react";
24
+ import type { PulseConnection, PulseStream } from "@sansavision/pulse-sdk";
25
+
26
+ interface Participant {
27
+ id: string;
28
+ name: string;
29
+ stream: MediaStream | null;
30
+ }
31
+
32
+ export default function VideoCallPage() {
33
+ const { data: session } = useSession();
34
+ const [connected, setConnected] = useState(false);
35
+ const [inCall, setInCall] = useState(false);
36
+ const [roomId, setRoomId] = useState("pulse-room-" + Math.random().toString(36).slice(2, 8));
37
+ const [joinRoomId, setJoinRoomId] = useState("");
38
+ const [localStream, setLocalStream] = useState<MediaStream | null>(null);
39
+ const [videoEnabled, setVideoEnabled] = useState(true);
40
+ const [audioEnabled, setAudioEnabled] = useState(true);
41
+ const [participants, setParticipants] = useState<Participant[]>([]);
42
+ const [connecting, setConnecting] = useState(true);
43
+ const [callDuration, setCallDuration] = useState(0);
44
+ const [copied, setCopied] = useState(false);
45
+ const [screenSharing, setScreenSharing] = useState(false);
46
+ const [isFullscreen, setIsFullscreen] = useState(false);
47
+
48
+ const localVideoRef = useRef<HTMLVideoElement>(null);
49
+ const connRef = useRef<PulseConnection | null>(null);
50
+ const signalStreamRef = useRef<PulseStream | null>(null);
51
+ const peerConnectionsRef = useRef<Map<string, RTCPeerConnection>>(new Map());
52
+ const callTimerRef = useRef<NodeJS.Timeout | null>(null);
53
+ const callContainerRef = useRef<HTMLDivElement>(null);
54
+
55
+ const userName = session?.user?.name || "Anonymous";
56
+ const userId = session?.user?.id || "unknown";
57
+
58
+ // ICE servers for WebRTC
59
+ const iceServers: RTCIceServer[] = [
60
+ { urls: "stun:stun.l.google.com:19302" },
61
+ { urls: "stun:stun1.l.google.com:19302" },
62
+ ];
63
+
64
+ // Connect to Pulse relay
65
+ useEffect(() => {
66
+ if (!session) return;
67
+ let cancelled = false;
68
+
69
+ async function init() {
70
+ try {
71
+ const connection = await connectWithAuth();
72
+ if (cancelled) return;
73
+ connRef.current = connection;
74
+ setConnected(true);
75
+ setConnecting(false);
76
+ connection.on("disconnect", () => setConnected(false));
77
+ connection.on("reconnected", () => setConnected(true));
78
+ } catch (err) {
79
+ console.error("Failed to connect:", err);
80
+ setConnecting(false);
81
+ }
82
+ }
83
+
84
+ init();
85
+ return () => {
86
+ cancelled = true;
87
+ connRef.current?.disconnect();
88
+ };
89
+ }, [session]);
90
+
91
+ // Get local media
92
+ const getLocalMedia = useCallback(async () => {
93
+ try {
94
+ const stream = await navigator.mediaDevices.getUserMedia({
95
+ video: {
96
+ width: { ideal: 1280 },
97
+ height: { ideal: 720 },
98
+ facingMode: "user",
99
+ },
100
+ audio: {
101
+ echoCancellation: true,
102
+ noiseSuppression: true,
103
+ },
104
+ });
105
+ setLocalStream(stream);
106
+ if (localVideoRef.current) {
107
+ localVideoRef.current.srcObject = stream;
108
+ }
109
+ return stream;
110
+ } catch (err) {
111
+ console.error("Failed to get media:", err);
112
+ // Try audio-only
113
+ try {
114
+ const audioStream = await navigator.mediaDevices.getUserMedia({
115
+ video: false,
116
+ audio: true,
117
+ });
118
+ setLocalStream(audioStream);
119
+ return audioStream;
120
+ } catch {
121
+ console.error("No media available");
122
+ return null;
123
+ }
124
+ }
125
+ }, []);
126
+
127
+ // Create RTCPeerConnection for a remote participant
128
+ const createPeerConnection = useCallback(
129
+ (remoteId: string, remoteName: string) => {
130
+ const pc = new RTCPeerConnection({ iceServers });
131
+
132
+ // Add local tracks
133
+ if (localStream) {
134
+ localStream.getTracks().forEach((track) => {
135
+ pc.addTrack(track, localStream);
136
+ });
137
+ }
138
+
139
+ // Handle incoming tracks
140
+ pc.ontrack = (event) => {
141
+ const [remoteStream] = event.streams;
142
+ setParticipants((prev) => {
143
+ const existing = prev.find((p) => p.id === remoteId);
144
+ if (existing) {
145
+ return prev.map((p) =>
146
+ p.id === remoteId ? { ...p, stream: remoteStream } : p
147
+ );
148
+ }
149
+ return [
150
+ ...prev,
151
+ { id: remoteId, name: remoteName, stream: remoteStream },
152
+ ];
153
+ });
154
+ };
155
+
156
+ // Handle ICE candidates
157
+ pc.onicecandidate = (event) => {
158
+ if (event.candidate && signalStreamRef.current) {
159
+ signalStreamRef.current.send(
160
+ JSON.stringify({
161
+ type: "ice-candidate",
162
+ candidate: event.candidate.toJSON(),
163
+ from: userId,
164
+ fromName: userName,
165
+ to: remoteId,
166
+ })
167
+ );
168
+ }
169
+ };
170
+
171
+ pc.oniceconnectionstatechange = () => {
172
+ if (
173
+ pc.iceConnectionState === "disconnected" ||
174
+ pc.iceConnectionState === "failed"
175
+ ) {
176
+ setParticipants((prev) => prev.filter((p) => p.id !== remoteId));
177
+ pc.close();
178
+ peerConnectionsRef.current.delete(remoteId);
179
+ }
180
+ };
181
+
182
+ peerConnectionsRef.current.set(remoteId, pc);
183
+ return pc;
184
+ },
185
+ [localStream, userId, userName]
186
+ );
187
+
188
+ // Join a video call room
189
+ const joinCall = useCallback(
190
+ async (room: string) => {
191
+ if (!connRef.current || !session) return;
192
+
193
+ const stream = await getLocalMedia();
194
+ if (!stream) return;
195
+
196
+ const signalStream = connRef.current.stream(`video-call:${room}`);
197
+ signalStreamRef.current = signalStream;
198
+
199
+ // Announce join
200
+ signalStream.send(
201
+ JSON.stringify({
202
+ type: "join",
203
+ from: userId,
204
+ fromName: userName,
205
+ room,
206
+ })
207
+ );
208
+
209
+ // Listen for signaling messages
210
+ signalStream.on("data", async (data: Uint8Array) => {
211
+ try {
212
+ const msg = JSON.parse(new TextDecoder().decode(data));
213
+
214
+ if (msg.type === "join" && msg.from !== userId) {
215
+ // New participant joined — send them an offer
216
+ const pc = createPeerConnection(msg.from, msg.fromName);
217
+ stream.getTracks().forEach((track) => {
218
+ pc.addTrack(track, stream);
219
+ });
220
+ const offer = await pc.createOffer();
221
+ await pc.setLocalDescription(offer);
222
+ signalStream.send(
223
+ JSON.stringify({
224
+ type: "offer",
225
+ sdp: offer.sdp,
226
+ from: userId,
227
+ fromName: userName,
228
+ to: msg.from,
229
+ })
230
+ );
231
+ } else if (msg.type === "offer" && msg.to === userId) {
232
+ // Received offer — create answer
233
+ const pc = createPeerConnection(msg.from, msg.fromName);
234
+ stream.getTracks().forEach((track) => {
235
+ pc.addTrack(track, stream);
236
+ });
237
+ await pc.setRemoteDescription(
238
+ new RTCSessionDescription({ type: "offer", sdp: msg.sdp })
239
+ );
240
+ const answer = await pc.createAnswer();
241
+ await pc.setLocalDescription(answer);
242
+ signalStream.send(
243
+ JSON.stringify({
244
+ type: "answer",
245
+ sdp: answer.sdp,
246
+ from: userId,
247
+ fromName: userName,
248
+ to: msg.from,
249
+ })
250
+ );
251
+ } else if (msg.type === "answer" && msg.to === userId) {
252
+ // Received answer
253
+ const pc = peerConnectionsRef.current.get(msg.from);
254
+ if (pc) {
255
+ await pc.setRemoteDescription(
256
+ new RTCSessionDescription({ type: "answer", sdp: msg.sdp })
257
+ );
258
+ }
259
+ } else if (msg.type === "ice-candidate" && msg.to === userId) {
260
+ // Received ICE candidate
261
+ const pc = peerConnectionsRef.current.get(msg.from);
262
+ if (pc && msg.candidate) {
263
+ await pc.addIceCandidate(new RTCIceCandidate(msg.candidate));
264
+ }
265
+ } else if (msg.type === "leave" && msg.from !== userId) {
266
+ // Participant left
267
+ const pc = peerConnectionsRef.current.get(msg.from);
268
+ if (pc) {
269
+ pc.close();
270
+ peerConnectionsRef.current.delete(msg.from);
271
+ }
272
+ setParticipants((prev) => prev.filter((p) => p.id !== msg.from));
273
+ }
274
+ } catch {
275
+ // ignore parse errors
276
+ }
277
+ });
278
+
279
+ setInCall(true);
280
+ setRoomId(room);
281
+
282
+ // Start call timer
283
+ callTimerRef.current = setInterval(() => {
284
+ setCallDuration((prev) => prev + 1);
285
+ }, 1000);
286
+ },
287
+ [session, userId, userName, getLocalMedia, createPeerConnection]
288
+ );
289
+
290
+ // Leave call
291
+ const leaveCall = useCallback(() => {
292
+ // Notify others
293
+ if (signalStreamRef.current) {
294
+ signalStreamRef.current.send(
295
+ JSON.stringify({
296
+ type: "leave",
297
+ from: userId,
298
+ fromName: userName,
299
+ })
300
+ );
301
+ }
302
+
303
+ // Close peer connections
304
+ peerConnectionsRef.current.forEach((pc) => pc.close());
305
+ peerConnectionsRef.current.clear();
306
+
307
+ // Stop local media
308
+ localStream?.getTracks().forEach((track) => track.stop());
309
+ setLocalStream(null);
310
+ setParticipants([]);
311
+ setInCall(false);
312
+ setCallDuration(0);
313
+ setScreenSharing(false);
314
+
315
+ if (callTimerRef.current) {
316
+ clearInterval(callTimerRef.current);
317
+ callTimerRef.current = null;
318
+ }
319
+ }, [localStream, userId, userName]);
320
+
321
+ // Toggle video
322
+ const toggleVideo = useCallback(() => {
323
+ if (localStream) {
324
+ localStream.getVideoTracks().forEach((track) => {
325
+ track.enabled = !track.enabled;
326
+ });
327
+ setVideoEnabled((prev) => !prev);
328
+ }
329
+ }, [localStream]);
330
+
331
+ // Toggle audio
332
+ const toggleAudio = useCallback(() => {
333
+ if (localStream) {
334
+ localStream.getAudioTracks().forEach((track) => {
335
+ track.enabled = !track.enabled;
336
+ });
337
+ setAudioEnabled((prev) => !prev);
338
+ }
339
+ }, [localStream]);
340
+
341
+ // Screen share
342
+ const toggleScreenShare = useCallback(async () => {
343
+ if (screenSharing) {
344
+ // Stop screen sharing, restore camera
345
+ const stream = await getLocalMedia();
346
+ if (stream) {
347
+ peerConnectionsRef.current.forEach((pc) => {
348
+ const senders = pc.getSenders();
349
+ const videoSender = senders.find((s) => s.track?.kind === "video");
350
+ const videoTrack = stream.getVideoTracks()[0];
351
+ if (videoSender && videoTrack) {
352
+ videoSender.replaceTrack(videoTrack);
353
+ }
354
+ });
355
+ }
356
+ setScreenSharing(false);
357
+ } else {
358
+ try {
359
+ const screenStream = await navigator.mediaDevices.getDisplayMedia({
360
+ video: true,
361
+ });
362
+ const screenTrack = screenStream.getVideoTracks()[0];
363
+
364
+ // Replace video track in all peer connections
365
+ peerConnectionsRef.current.forEach((pc) => {
366
+ const senders = pc.getSenders();
367
+ const videoSender = senders.find(
368
+ (s) => s.track?.kind === "video"
369
+ );
370
+ if (videoSender) {
371
+ videoSender.replaceTrack(screenTrack);
372
+ }
373
+ });
374
+
375
+ // Update local video
376
+ if (localVideoRef.current) {
377
+ localVideoRef.current.srcObject = screenStream;
378
+ }
379
+ setScreenSharing(true);
380
+
381
+ // When user stops screen share from browser
382
+ screenTrack.onended = () => {
383
+ toggleScreenShare();
384
+ };
385
+ } catch {
386
+ console.error("Screen share cancelled");
387
+ }
388
+ }
389
+ }, [screenSharing, getLocalMedia]);
390
+
391
+ // Copy room ID
392
+ const copyRoomId = useCallback(() => {
393
+ navigator.clipboard.writeText(roomId);
394
+ setCopied(true);
395
+ setTimeout(() => setCopied(false), 2000);
396
+ }, [roomId]);
397
+
398
+ // Fullscreen toggle
399
+ const toggleFullscreen = useCallback(() => {
400
+ const container = callContainerRef.current;
401
+ if (!container) return;
402
+ if (!document.fullscreenElement) {
403
+ container.requestFullscreen().then(() => setIsFullscreen(true)).catch(() => { });
404
+ } else {
405
+ document.exitFullscreen().then(() => setIsFullscreen(false)).catch(() => { });
406
+ }
407
+ }, []);
408
+
409
+ useEffect(() => {
410
+ const handler = () => setIsFullscreen(!!document.fullscreenElement);
411
+ document.addEventListener("fullscreenchange", handler);
412
+ return () => document.removeEventListener("fullscreenchange", handler);
413
+ }, []);
414
+
415
+ // Format call duration
416
+ const formatDuration = (seconds: number) => {
417
+ const mins = Math.floor(seconds / 60);
418
+ const secs = seconds % 60;
419
+ return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
420
+ };
421
+
422
+ // Attach local stream to video element whenever it changes
423
+ useEffect(() => {
424
+ if (localVideoRef.current && localStream && inCall) {
425
+ localVideoRef.current.srcObject = localStream;
426
+ }
427
+ }, [localStream, inCall]);
428
+
429
+ // Clean up on unmount
430
+ useEffect(() => {
431
+ return () => {
432
+ localStream?.getTracks().forEach((track) => track.stop());
433
+ peerConnectionsRef.current.forEach((pc) => pc.close());
434
+ if (callTimerRef.current) clearInterval(callTimerRef.current);
435
+ };
436
+ }, [localStream]);
437
+
438
+ // Total grid items = local + remote participants
439
+ const totalVideos = 1 + participants.length;
440
+ const gridCols =
441
+ totalVideos <= 1
442
+ ? "grid-cols-1"
443
+ : totalVideos <= 4
444
+ ? "grid-cols-2"
445
+ : totalVideos <= 9
446
+ ? "grid-cols-3"
447
+ : "grid-cols-4";
448
+
449
+ return (
450
+ <div ref={callContainerRef} className="h-screen flex flex-col bg-[#0f172a]">
451
+ {/* Header */}
452
+ <div className="p-4 border-b border-slate-800 flex items-center justify-between">
453
+ <div className="flex items-center gap-3">
454
+ <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500 to-sky-500 flex items-center justify-center">
455
+ <Video className="w-5 h-5 text-white" />
456
+ </div>
457
+ <div>
458
+ <h1 className="text-lg font-semibold">Video Call</h1>
459
+ <p className="text-xs text-slate-500">
460
+ {inCall
461
+ ? `Room: ${roomId} · ${formatDuration(callDuration)}`
462
+ : "Peer-to-peer video via Pulse signaling"}
463
+ </p>
464
+ </div>
465
+ </div>
466
+ <div className="flex items-center gap-4">
467
+ <div className="flex items-center gap-2">
468
+ {connected ? (
469
+ <>
470
+ <Wifi className="w-4 h-4 text-green-400" />
471
+ <span className="text-xs text-green-400">Connected</span>
472
+ </>
473
+ ) : (
474
+ <>
475
+ <WifiOff className="w-4 h-4 text-red-400" />
476
+ <span className="text-xs text-red-400">Disconnected</span>
477
+ </>
478
+ )}
479
+ </div>
480
+ {inCall && (
481
+ <div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-slate-800">
482
+ <Users className="w-3.5 h-3.5 text-slate-400" />
483
+ <span className="text-xs text-slate-400">
484
+ {participants.length + 1} in call
485
+ </span>
486
+ </div>
487
+ )}
488
+ </div>
489
+ </div>
490
+
491
+ {/* Main Content */}
492
+ <div className="flex-1 overflow-hidden">
493
+ {connecting ? (
494
+ <div className="flex items-center justify-center h-full">
495
+ <Loader2 className="w-6 h-6 animate-spin text-purple-500 mr-2" />
496
+ <span className="text-slate-400">Connecting to Pulse relay...</span>
497
+ </div>
498
+ ) : !inCall ? (
499
+ /* Lobby */
500
+ <div className="flex items-center justify-center h-full p-8">
501
+ <div className="max-w-lg w-full space-y-8">
502
+ {/* Create Room */}
503
+ <div className="glass rounded-2xl p-8">
504
+ <div className="flex items-center gap-3 mb-6">
505
+ <div className="w-12 h-12 rounded-xl bg-gradient-to-br from-indigo-500 to-sky-500 flex items-center justify-center">
506
+ <Video className="w-6 h-6 text-white" />
507
+ </div>
508
+ <div>
509
+ <h2 className="text-xl font-semibold">Start a Video Call</h2>
510
+ <p className="text-sm text-slate-400">
511
+ Create a room and invite others
512
+ </p>
513
+ </div>
514
+ </div>
515
+ <div className="flex items-center gap-2 mb-4">
516
+ <div className="flex-1 px-4 py-2.5 rounded-lg bg-slate-800/50 border border-slate-700 text-sm font-mono text-cyan-400">
517
+ {roomId}
518
+ </div>
519
+ <button
520
+ onClick={copyRoomId}
521
+ className="px-3 py-2.5 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700 transition-colors"
522
+ >
523
+ {copied ? (
524
+ <Check className="w-4 h-4 text-green-400" />
525
+ ) : (
526
+ <Copy className="w-4 h-4 text-slate-400" />
527
+ )}
528
+ </button>
529
+ </div>
530
+ <button
531
+ onClick={() => joinCall(roomId)}
532
+ disabled={!connected}
533
+ className="w-full py-3 rounded-xl bg-gradient-to-r from-indigo-600 to-sky-600 hover:from-indigo-500 hover:to-sky-500 text-sm font-semibold transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
534
+ >
535
+ <PhoneCall className="w-4 h-4" />
536
+ Start Call
537
+ </button>
538
+ </div>
539
+
540
+ {/* Join Room */}
541
+ <div className="glass rounded-2xl p-8">
542
+ <h2 className="text-lg font-semibold mb-4">Join Existing Room</h2>
543
+ <div className="flex gap-2">
544
+ <input
545
+ type="text"
546
+ value={joinRoomId}
547
+ onChange={(e) => setJoinRoomId(e.target.value)}
548
+ placeholder="Enter room ID..."
549
+ className="flex-1 px-4 py-2.5 rounded-xl bg-slate-800/50 border border-slate-700 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none text-sm transition-colors placeholder:text-slate-600"
550
+ />
551
+ <button
552
+ onClick={() => joinRoomId && joinCall(joinRoomId)}
553
+ disabled={!connected || !joinRoomId.trim()}
554
+ className="px-6 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-sm font-semibold transition-all disabled:opacity-50 disabled:cursor-not-allowed"
555
+ >
556
+ Join
557
+ </button>
558
+ </div>
559
+ </div>
560
+
561
+ {/* Info */}
562
+ <div className="glass rounded-xl p-5 border-l-4 border-indigo-500">
563
+ <h3 className="text-sm font-semibold text-indigo-400 mb-1">
564
+ 💡 How It Works
565
+ </h3>
566
+ <p className="text-sm text-slate-400">
567
+ Video calls use Pulse as the signaling layer for WebRTC peer
568
+ connections. The relay handles SDP offer/answer exchange and ICE
569
+ candidate relay — media flows peer-to-peer.
570
+ </p>
571
+ </div>
572
+ </div>
573
+ </div>
574
+ ) : (
575
+ /* In Call — Video Grid */
576
+ <div className="h-full p-4 flex flex-col">
577
+ <div className={`flex-1 grid ${gridCols} gap-3 auto-rows-fr`}>
578
+ {/* Local Video */}
579
+ <div className="relative rounded-2xl overflow-hidden bg-slate-900 border border-slate-800">
580
+ <video
581
+ ref={localVideoRef}
582
+ autoPlay
583
+ playsInline
584
+ muted
585
+ className={`w-full h-full object-cover ${!videoEnabled ? "hidden" : ""}`}
586
+ />
587
+ {!videoEnabled && (
588
+ <div className="absolute inset-0 flex items-center justify-center">
589
+ <div className="w-20 h-20 rounded-full bg-gradient-to-br from-indigo-500 to-sky-500 flex items-center justify-center text-2xl font-bold">
590
+ {userName.charAt(0).toUpperCase()}
591
+ </div>
592
+ </div>
593
+ )}
594
+ <div className="absolute bottom-3 left-3 flex items-center gap-2">
595
+ <span className="px-2.5 py-1 rounded-md bg-black/60 backdrop-blur text-xs font-medium">
596
+ {userName} (You)
597
+ </span>
598
+ {!audioEnabled && (
599
+ <span className="px-2 py-1 rounded-md bg-red-500/80 backdrop-blur text-xs">
600
+ <MicOff className="w-3 h-3" />
601
+ </span>
602
+ )}
603
+ </div>
604
+ {screenSharing && (
605
+ <div className="absolute top-3 left-3 flex items-center gap-1.5 px-2.5 py-1 rounded-md bg-green-500/80 backdrop-blur text-xs font-medium">
606
+ <Monitor className="w-3 h-3" />
607
+ Sharing Screen
608
+ </div>
609
+ )}
610
+ </div>
611
+
612
+ {/* Remote Participants */}
613
+ {participants.map((participant) => (
614
+ <RemoteVideo
615
+ key={participant.id}
616
+ participant={participant}
617
+ />
618
+ ))}
619
+
620
+ {/* Empty slots for visual balance */}
621
+ {participants.length === 0 && (
622
+ <div className="rounded-2xl border-2 border-dashed border-slate-800 flex items-center justify-center">
623
+ <div className="text-center">
624
+ <Users className="w-8 h-8 text-slate-700 mx-auto mb-2" />
625
+ <p className="text-sm text-slate-600">Waiting for others...</p>
626
+ <p className="text-xs text-slate-700 mt-1">
627
+ Share room ID: <span className="text-cyan-500 font-mono">{roomId}</span>
628
+ </p>
629
+ </div>
630
+ </div>
631
+ )}
632
+ </div>
633
+
634
+ {/* Call Controls */}
635
+ <div className="flex items-center justify-center gap-3 py-4 mt-3">
636
+ <button
637
+ onClick={toggleAudio}
638
+ className={`w-12 h-12 rounded-full flex items-center justify-center transition-all ${audioEnabled
639
+ ? "bg-slate-700 hover:bg-slate-600"
640
+ : "bg-red-500 hover:bg-red-400"
641
+ }`}
642
+ >
643
+ {audioEnabled ? (
644
+ <Mic className="w-5 h-5" />
645
+ ) : (
646
+ <MicOff className="w-5 h-5" />
647
+ )}
648
+ </button>
649
+ <button
650
+ onClick={toggleVideo}
651
+ className={`w-12 h-12 rounded-full flex items-center justify-center transition-all ${videoEnabled
652
+ ? "bg-slate-700 hover:bg-slate-600"
653
+ : "bg-red-500 hover:bg-red-400"
654
+ }`}
655
+ >
656
+ {videoEnabled ? (
657
+ <Video className="w-5 h-5" />
658
+ ) : (
659
+ <VideoOff className="w-5 h-5" />
660
+ )}
661
+ </button>
662
+ <button
663
+ onClick={toggleScreenShare}
664
+ className={`w-12 h-12 rounded-full flex items-center justify-center transition-all ${screenSharing
665
+ ? "bg-green-500 hover:bg-green-400"
666
+ : "bg-slate-700 hover:bg-slate-600"
667
+ }`}
668
+ >
669
+ <Monitor className="w-5 h-5" />
670
+ </button>
671
+ <button
672
+ onClick={toggleFullscreen}
673
+ className="w-12 h-12 rounded-full bg-slate-700 hover:bg-slate-600 flex items-center justify-center transition-all"
674
+ title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}
675
+ >
676
+ {isFullscreen ? (
677
+ <Minimize className="w-5 h-5" />
678
+ ) : (
679
+ <Maximize className="w-5 h-5" />
680
+ )}
681
+ </button>
682
+ <button
683
+ onClick={copyRoomId}
684
+ className="w-12 h-12 rounded-full bg-slate-700 hover:bg-slate-600 flex items-center justify-center transition-all"
685
+ title="Copy Room ID"
686
+ >
687
+ {copied ? (
688
+ <Check className="w-5 h-5 text-green-400" />
689
+ ) : (
690
+ <Copy className="w-5 h-5" />
691
+ )}
692
+ </button>
693
+ <button
694
+ onClick={leaveCall}
695
+ className="w-14 h-12 rounded-full bg-red-600 hover:bg-red-500 flex items-center justify-center transition-all"
696
+ >
697
+ <PhoneOff className="w-5 h-5" />
698
+ </button>
699
+ </div>
700
+ </div>
701
+ )}
702
+ </div>
703
+ </div>
704
+ );
705
+ }
706
+
707
+ /* Remote video component */
708
+ function RemoteVideo({ participant }: { participant: Participant }) {
709
+ const videoRef = useRef<HTMLVideoElement>(null);
710
+
711
+ useEffect(() => {
712
+ if (videoRef.current && participant.stream) {
713
+ videoRef.current.srcObject = participant.stream;
714
+ }
715
+ }, [participant.stream]);
716
+
717
+ return (
718
+ <div className="relative rounded-2xl overflow-hidden bg-slate-900 border border-slate-800">
719
+ {participant.stream ? (
720
+ <video
721
+ ref={videoRef}
722
+ autoPlay
723
+ playsInline
724
+ className="w-full h-full object-cover"
725
+ />
726
+ ) : (
727
+ <div className="absolute inset-0 flex items-center justify-center">
728
+ <div className="w-20 h-20 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-2xl font-bold">
729
+ {participant.name.charAt(0).toUpperCase()}
730
+ </div>
731
+ </div>
732
+ )}
733
+ <div className="absolute bottom-3 left-3">
734
+ <span className="px-2.5 py-1 rounded-md bg-black/60 backdrop-blur text-xs font-medium">
735
+ {participant.name}
736
+ </span>
737
+ </div>
738
+ </div>
739
+ );
740
+ }