@sansavision/create-pulse 0.4.4 → 0.4.6
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.
- package/dist/index.js +2 -0
- package/package.json +2 -2
- package/templates/aurora-auth-node-demo/README.md +43 -0
- package/templates/aurora-auth-node-demo/aurora.config.ts +15 -0
- package/templates/aurora-auth-node-demo/bun.lock +679 -0
- package/templates/aurora-auth-node-demo/drizzle.config.ts +9 -0
- package/templates/aurora-auth-node-demo/package.json +39 -0
- package/templates/aurora-auth-node-demo/postcss.config.mjs +7 -0
- package/templates/aurora-auth-node-demo/server.mjs +46 -0
- package/templates/aurora-auth-node-demo/src/actions/createMessage.action.server.ts +31 -0
- package/templates/aurora-auth-node-demo/src/aurora.auth.ts +65 -0
- package/templates/aurora-auth-node-demo/src/lib/auth-client.ts +30 -0
- package/templates/aurora-auth-node-demo/src/lib/auth.server.ts +11 -0
- package/templates/aurora-auth-node-demo/src/lib/auth.ts +30 -0
- package/templates/aurora-auth-node-demo/src/lib/db.ts +6 -0
- package/templates/aurora-auth-node-demo/src/lib/pulse.ts +45 -0
- package/templates/aurora-auth-node-demo/src/lib/schema.ts +107 -0
- package/templates/aurora-auth-node-demo/src/queries/listMessages.server.ts +25 -0
- package/templates/aurora-auth-node-demo/src/routes/api/auth/[...slug]/handler.ts +14 -0
- package/templates/aurora-auth-node-demo/src/routes/api/pulse/verify/handler.ts +55 -0
- package/templates/aurora-auth-node-demo/src/routes/auth/sign-in/page.client.tsx +132 -0
- package/templates/aurora-auth-node-demo/src/routes/auth/sign-in/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/auth/sign-up/page.client.tsx +154 -0
- package/templates/aurora-auth-node-demo/src/routes/auth/sign-up/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/arena-game/page.client.tsx +640 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/arena-game/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/chat/page.client.tsx +349 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/chat/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/encrypted-chat/page.client.tsx +472 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/encrypted-chat/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/game-sync/page.client.tsx +375 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/game-sync/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/queues/page.client.tsx +423 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/queues/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/video-call/page.client.tsx +840 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/video-call/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/watch-together/page.client.tsx +722 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/watch-together/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/layout.client.tsx +113 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/layout.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/page.client.tsx +195 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/favicon.ico +0 -0
- package/templates/aurora-auth-node-demo/src/routes/layout.tsx +18 -0
- package/templates/aurora-auth-node-demo/src/routes/page.client.tsx +263 -0
- package/templates/aurora-auth-node-demo/src/routes/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/styles/app.css +96 -0
- package/templates/aurora-auth-node-demo/tsconfig.json +27 -0
- package/templates/aurora-auth-node-demo/tsconfig.tsbuildinfo +1 -0
- package/templates/nextjs-auth-demo/next-env.d.ts +1 -1
- package/templates/nextjs-auth-demo/package.json +8 -7
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/arena-game/page.tsx +20 -3
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/chat/page.tsx +108 -23
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/encrypted-chat/page.tsx +278 -217
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/game-sync/page.tsx +66 -35
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/queues/page.tsx +213 -87
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/video-call/page.tsx +106 -6
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/watch-together/page.tsx +415 -262
- package/templates/nextjs-auth-node-demo/.env.example +10 -0
- package/templates/nextjs-auth-node-demo/Dockerfile +19 -0
- package/templates/nextjs-auth-node-demo/README.md +159 -0
- package/templates/nextjs-auth-node-demo/_gitignore +33 -0
- package/templates/nextjs-auth-node-demo/drizzle.config.ts +10 -0
- package/templates/nextjs-auth-node-demo/eslint.config.mjs +18 -0
- package/templates/nextjs-auth-node-demo/next-env.d.ts +6 -0
- package/templates/nextjs-auth-node-demo/next.config.ts +7 -0
- package/templates/nextjs-auth-node-demo/package.json +38 -0
- package/templates/nextjs-auth-node-demo/postcss.config.mjs +7 -0
- package/templates/nextjs-auth-node-demo/public/file.svg +1 -0
- package/templates/nextjs-auth-node-demo/public/globe.svg +1 -0
- package/templates/nextjs-auth-node-demo/public/next.svg +1 -0
- package/templates/nextjs-auth-node-demo/public/vercel.svg +1 -0
- package/templates/nextjs-auth-node-demo/public/window.svg +1 -0
- package/templates/nextjs-auth-node-demo/server.mjs +45 -0
- package/templates/nextjs-auth-node-demo/src/app/api/auth/[...all]/route.ts +4 -0
- package/templates/nextjs-auth-node-demo/src/app/api/pulse/verify/route.ts +54 -0
- package/templates/nextjs-auth-node-demo/src/app/auth/sign-in/page.tsx +131 -0
- package/templates/nextjs-auth-node-demo/src/app/auth/sign-up/page.tsx +153 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/arena-game/page.tsx +640 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/chat/page.tsx +349 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/encrypted-chat/page.tsx +472 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/game-sync/page.tsx +375 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/queues/page.tsx +423 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/video-call/page.tsx +840 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/watch-together/page.tsx +724 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/layout.tsx +113 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/page.tsx +195 -0
- package/templates/nextjs-auth-node-demo/src/app/favicon.ico +0 -0
- package/templates/nextjs-auth-node-demo/src/app/globals.css +96 -0
- package/templates/nextjs-auth-node-demo/src/app/layout.tsx +27 -0
- package/templates/nextjs-auth-node-demo/src/app/page.tsx +254 -0
- package/templates/nextjs-auth-node-demo/src/lib/auth-client.ts +15 -0
- package/templates/nextjs-auth-node-demo/src/lib/auth.ts +14 -0
- package/templates/nextjs-auth-node-demo/src/lib/db.ts +6 -0
- package/templates/nextjs-auth-node-demo/src/lib/pulse.ts +45 -0
- package/templates/nextjs-auth-node-demo/src/lib/schema.ts +107 -0
- 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
|
+
}
|