@sansavision/create-pulse 0.4.4 → 0.4.5

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