@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
@@ -4,23 +4,14 @@ import { useState, useEffect, useRef, useCallback } from "react";
4
4
  import { useSession } from "@/lib/auth-client";
5
5
  import { connectWithAuth } from "@/lib/pulse";
6
6
  import {
7
- Video,
8
- Play,
9
- Pause,
10
- SkipForward,
11
- Users,
12
- Shield,
13
- Wifi,
14
- WifiOff,
15
- Loader2,
16
- Link2,
17
- ExternalLink,
18
- ListVideo,
19
- Check,
20
- Maximize,
21
- Minimize,
7
+ Video, Play, Pause, SkipForward, Users, Shield, Wifi, WifiOff, Loader2,
8
+ Link2, ExternalLink, ListVideo, Check, Maximize, Minimize, Settings, Volume2, VolumeX, SkipBack,
9
+ AlertCircle, ChevronDown, Square
22
10
  } from "lucide-react";
23
11
  import type { PulseConnection } from "@sansavision/pulse-sdk";
12
+ import dynamic from "next/dynamic";
13
+
14
+ const ReactPlayer = dynamic(() => import("react-player"), { ssr: false });
24
15
 
25
16
  interface VideoSource {
26
17
  label: string;
@@ -69,6 +60,9 @@ export default function WatchTogetherPage() {
69
60
  const [connected, setConnected] = useState(false);
70
61
  const [authUser, setAuthUser] = useState<{ id: string; claims: Record<string, string> } | null>(null);
71
62
  const [connecting, setConnecting] = useState(true);
63
+ const [roomId, setRoomId] = useState("");
64
+ const [joinRoomId, setJoinRoomId] = useState("");
65
+ const [joinError, setJoinError] = useState("");
72
66
  const [playing, setPlaying] = useState(false);
73
67
  const [currentTime, setCurrentTime] = useState(0);
74
68
  const [duration, setDuration] = useState(0);
@@ -78,13 +72,29 @@ export default function WatchTogetherPage() {
78
72
  const [customUrl, setCustomUrl] = useState("");
79
73
  const [showPicker, setShowPicker] = useState(false);
80
74
  const [isFullscreen, setIsFullscreen] = useState(false);
75
+ const [volume, setVolume] = useState(1);
76
+ const [quality, setQuality] = useState("AUTO");
77
+ const [showQualityMenu, setShowQualityMenu] = useState(false);
78
+ const [metrics, setMetrics] = useState({ rttMs: 0, msgCount: 0, watcherCount: 1 });
79
+
81
80
  const connRef = useRef<PulseConnection | null>(null);
82
- const videoRef = useRef<HTMLVideoElement>(null);
83
- const iframeRef = useRef<HTMLIFrameElement>(null);
81
+ const streamRef = useRef<ReturnType<PulseConnection["stream"]> | null>(null);
82
+ const playerRef = useRef<any>(null);
84
83
  const playerContainerRef = useRef<HTMLDivElement>(null);
85
84
  const ignoreSyncRef = useRef(false);
85
+ const sessionRef = useRef(session);
86
+ const playingRef = useRef(false);
87
+ const roomIdRef = useRef(roomId);
88
+ const [viewerStates, setViewerStates] = useState<Record<string, { playing: boolean; time: number; synced: boolean }>>({});
89
+
90
+ useEffect(() => { sessionRef.current = session; }, [session]);
91
+ useEffect(() => { playingRef.current = playing; }, [playing]);
92
+ useEffect(() => { roomIdRef.current = roomId; }, [roomId]);
93
+
94
+ // Track active source in a ref so heartbeat/send closures always see latest
95
+ const activeSourceRef = useRef(activeSource);
96
+ useEffect(() => { activeSourceRef.current = activeSource; }, [activeSource]);
86
97
 
87
- // Connect to Pulse relay
88
98
  useEffect(() => {
89
99
  if (!session) return;
90
100
  let cancelled = false;
@@ -102,71 +112,6 @@ export default function WatchTogetherPage() {
102
112
  const user = (connection as any).user;
103
113
  if (user) setAuthUser(user);
104
114
 
105
- const stream = connection.stream("watch-room");
106
-
107
- stream.send(
108
- JSON.stringify({ type: "viewer-join", name: session!.user.name })
109
- );
110
-
111
- stream.on("data", (data: Uint8Array) => {
112
- try {
113
- const msg = JSON.parse(new TextDecoder().decode(data));
114
- if (msg.type === "play") {
115
- setPlaying(true);
116
- if (videoRef.current) {
117
- ignoreSyncRef.current = true;
118
- videoRef.current.currentTime = msg.time || 0;
119
- videoRef.current.play().catch(() => { });
120
- setTimeout(() => { ignoreSyncRef.current = false; }, 200);
121
- }
122
- setSyncEvents((prev) => [
123
- `${msg.user} pressed play at ${msg.time?.toFixed(1)}s`,
124
- ...prev.slice(0, 19),
125
- ]);
126
- } else if (msg.type === "pause") {
127
- setPlaying(false);
128
- if (videoRef.current) {
129
- ignoreSyncRef.current = true;
130
- videoRef.current.pause();
131
- setTimeout(() => { ignoreSyncRef.current = false; }, 200);
132
- }
133
- setSyncEvents((prev) => [
134
- `${msg.user} paused at ${msg.time?.toFixed(1)}s`,
135
- ...prev.slice(0, 19),
136
- ]);
137
- } else if (msg.type === "seek") {
138
- if (videoRef.current) {
139
- ignoreSyncRef.current = true;
140
- videoRef.current.currentTime = msg.time || 0;
141
- setTimeout(() => { ignoreSyncRef.current = false; }, 200);
142
- }
143
- setCurrentTime(msg.time || 0);
144
- setSyncEvents((prev) => [
145
- `${msg.user} seeked to ${msg.time?.toFixed(1)}s`,
146
- ...prev.slice(0, 19),
147
- ]);
148
- } else if (msg.type === "viewer-join") {
149
- setViewers((prev) =>
150
- prev.includes(msg.name) ? prev : [...prev, msg.name]
151
- );
152
- setSyncEvents((prev) => [
153
- `${msg.name} joined the watch party`,
154
- ...prev.slice(0, 19),
155
- ]);
156
- } else if (msg.type === "source-change") {
157
- setActiveSource(msg.source);
158
- setCurrentTime(0);
159
- setPlaying(false);
160
- setSyncEvents((prev) => [
161
- `${msg.user} changed video to "${msg.source.label}"`,
162
- ...prev.slice(0, 19),
163
- ]);
164
- }
165
- } catch {
166
- /* ignore */
167
- }
168
- });
169
-
170
115
  connection.on("disconnect", () => setConnected(false));
171
116
  connection.on("reconnected", () => setConnected(true));
172
117
  } catch (err) {
@@ -182,85 +127,212 @@ export default function WatchTogetherPage() {
182
127
  };
183
128
  }, [session]);
184
129
 
185
- // Sync video time updates
130
+ const getPlayerTime = () => playerRef.current?.getCurrentTime() || 0;
131
+ const seekPlayerTo = (time: number) => {
132
+ if (!playerRef.current) return;
133
+ ignoreSyncRef.current = true;
134
+ playerRef.current.seekTo(time, "seconds");
135
+ setTimeout(() => { ignoreSyncRef.current = false; }, 200);
136
+ };
137
+
186
138
  useEffect(() => {
187
- const video = videoRef.current;
188
- if (!video) return;
139
+ if (!roomId || !connected || !connRef.current) return;
140
+ const sess = sessionRef.current;
141
+ if (!sess) return;
189
142
 
190
- const onTimeUpdate = () => {
191
- setCurrentTime(video.currentTime);
192
- };
193
- const onDurationChange = () => {
194
- setDuration(video.duration || 0);
195
- };
196
- const onPlay = () => {
197
- if (!ignoreSyncRef.current) {
198
- setPlaying(true);
199
- sendAction("play");
200
- }
201
- };
202
- const onPause = () => {
203
- if (!ignoreSyncRef.current) {
204
- setPlaying(false);
205
- sendAction("pause");
206
- }
207
- };
208
- const onSeeked = () => {
209
- if (!ignoreSyncRef.current) {
210
- sendAction("seek");
143
+ const stream = connRef.current.stream(`watch-room-${roomId}`);
144
+ streamRef.current = stream;
145
+
146
+ // Broadcast join immediately
147
+ stream.send(JSON.stringify({ type: "viewer-join", name: sess.user.name, user: sess.user.name }));
148
+ setViewers([sess.user.name]);
149
+ setMetrics(m => ({ ...m, watcherCount: 1 }));
150
+
151
+ const handleData = (data: Uint8Array) => {
152
+ try {
153
+ const msg = JSON.parse(new TextDecoder().decode(data));
154
+ const self = sessionRef.current;
155
+ if (!self) return;
156
+
157
+ // Measure real RTT from message timestamps (skip heartbeats to avoid inflating count)
158
+ if (msg.sentAt && msg.type !== "heartbeat") {
159
+ const rtt = Date.now() - msg.sentAt;
160
+ setMetrics(m => ({ ...m, rttMs: Math.max(0, rtt), msgCount: m.msgCount + 1 }));
161
+ }
162
+
163
+ if (msg.type === "play" && msg.user !== self.user.name) {
164
+ setPlaying(true);
165
+ setSyncEvents((prev) => [`${msg.user} pressed play`, ...prev.slice(0, 19)]);
166
+ } else if (msg.type === "pause" && msg.user !== self.user.name) {
167
+ setPlaying(false);
168
+ setSyncEvents((prev) => [`${msg.user} paused`, ...prev.slice(0, 19)]);
169
+ } else if (msg.type === "seek" && msg.user !== self.user.name) {
170
+ seekPlayerTo(msg.time || 0);
171
+ setCurrentTime(msg.time || 0);
172
+ setSyncEvents((prev) => [`${msg.user} seeked to ${formatTime(msg.time)}`, ...prev.slice(0, 19)]);
173
+ } else if (msg.type === "heartbeat" && msg.user !== self.user.name) {
174
+ if (typeof msg.time === "number") {
175
+ const myTime = getPlayerTime();
176
+ const theirTime = msg.time;
177
+ const forwardDrift = theirTime - myTime;
178
+ const isSynced = Math.abs(myTime - theirTime) < 2;
179
+
180
+ setViewerStates(prev => ({
181
+ ...prev,
182
+ [msg.user]: { playing: !!msg.playing, time: theirTime, synced: isSynced }
183
+ }));
184
+
185
+ // Auto-sync: only seek FORWARD to avoid ping-pong
186
+ if (forwardDrift > 2) {
187
+ seekPlayerTo(theirTime);
188
+ }
189
+ if (msg.playing && !playingRef.current) {
190
+ seekPlayerTo(theirTime);
191
+ setPlaying(true);
192
+ }
193
+ }
194
+ } else if (msg.type === "viewer-join" && msg.name !== self.user.name) {
195
+ setViewers((prev) => {
196
+ const newViewers = prev.includes(msg.name) ? prev : [...prev, msg.name];
197
+ setMetrics(m => ({ ...m, watcherCount: newViewers.length }));
198
+ return newViewers;
199
+ });
200
+ setSyncEvents((prev) => [`${msg.name} joined`, ...prev.slice(0, 19)]);
201
+
202
+ try {
203
+ stream.send(JSON.stringify({
204
+ type: "viewer-presence",
205
+ name: self.user.name,
206
+ user: self.user.name,
207
+ time: getPlayerTime(),
208
+ playing: playingRef.current,
209
+ source: activeSourceRef.current,
210
+ sentAt: Date.now(),
211
+ }));
212
+ } catch { /* ignore */ }
213
+ } else if (msg.type === "viewer-presence" && msg.name !== self.user.name) {
214
+ setViewers((prev) => {
215
+ const newViewers = prev.includes(msg.name) ? prev : [...prev, msg.name];
216
+ setMetrics(m => ({ ...m, watcherCount: newViewers.length }));
217
+ return newViewers;
218
+ });
219
+ if (msg.source) {
220
+ setActiveSource(msg.source);
221
+ }
222
+ if (typeof msg.time === "number" && msg.time > 0) {
223
+ seekPlayerTo(msg.time);
224
+ if (msg.playing) {
225
+ setPlaying(true);
226
+ }
227
+ }
228
+ } else if (msg.type === "source-change" && msg.user !== self.user.name) {
229
+ setActiveSource(msg.source);
230
+ setCurrentTime(0);
231
+ setPlaying(false);
232
+ setSyncEvents((prev) => [`${msg.user} changed video to "${msg.source.label}"`, ...prev.slice(0, 19)]);
233
+ }
234
+ } catch (err) {
235
+ console.error("Critical error in handleData:", err);
211
236
  }
212
237
  };
213
238
 
214
- video.addEventListener("timeupdate", onTimeUpdate);
215
- video.addEventListener("durationchange", onDurationChange);
216
- video.addEventListener("play", onPlay);
217
- video.addEventListener("pause", onPause);
218
- video.addEventListener("seeked", onSeeked);
239
+ stream.on("data", handleData);
240
+
241
+ const heartbeatInterval = setInterval(() => {
242
+ const sess = sessionRef.current;
243
+ const activeStream = streamRef.current;
244
+ if (playingRef.current && sess && activeStream) {
245
+ try {
246
+ activeStream.send(JSON.stringify({
247
+ type: "heartbeat",
248
+ user: sess.user.name,
249
+ time: getPlayerTime(),
250
+ playing: true,
251
+ sentAt: Date.now(),
252
+ }));
253
+ } catch { }
254
+ }
255
+ }, 3000);
219
256
 
220
257
  return () => {
221
- video.removeEventListener("timeupdate", onTimeUpdate);
222
- video.removeEventListener("durationchange", onDurationChange);
223
- video.removeEventListener("play", onPlay);
224
- video.removeEventListener("pause", onPause);
225
- video.removeEventListener("seeked", onSeeked);
258
+ clearInterval(heartbeatInterval);
259
+ stream.close();
260
+ streamRef.current = null;
226
261
  };
227
- // eslint-disable-next-line react-hooks/exhaustive-deps
228
- }, [activeSource]);
229
-
230
- const sendAction = useCallback((type: string) => {
231
- if (!connRef.current) return;
232
- const stream = connRef.current.stream("watch-room");
233
- stream.send(
234
- JSON.stringify({
235
- type,
236
- user: session?.user?.name,
237
- time: videoRef.current?.currentTime ?? currentTime,
238
- })
239
- );
240
- }, [session, currentTime]);
262
+ }, [roomId, connected]);
241
263
 
242
- function handlePlayPause() {
243
- const video = videoRef.current;
244
- if (!video) return;
245
- if (video.paused) {
246
- video.play().catch(() => { });
247
- } else {
248
- video.pause();
264
+ function sendToStream(type: string) {
265
+ if (!streamRef.current) return;
266
+ const sess = sessionRef.current;
267
+ try {
268
+ streamRef.current.send(
269
+ JSON.stringify({
270
+ type,
271
+ user: sess?.user?.name,
272
+ name: sess?.user?.name,
273
+ time: getPlayerTime(),
274
+ sentAt: Date.now(),
275
+ })
276
+ );
277
+ } catch {
278
+ // Connection may have dropped — ignore silently
249
279
  }
250
280
  }
251
281
 
282
+ // Handlers mapped to ReactPlayer props
283
+ const onPlay = () => {
284
+ if (!ignoreSyncRef.current) {
285
+ setPlaying(true);
286
+ sendToStream("play");
287
+ }
288
+ };
289
+
290
+ const onPause = () => {
291
+ if (!ignoreSyncRef.current) {
292
+ setPlaying(false);
293
+ sendToStream("pause");
294
+ }
295
+ };
296
+
297
+ // ReactPlayer only calls onSeek when the user manually seeks.
298
+ const onSeeked = () => {
299
+ if (!ignoreSyncRef.current) {
300
+ sendToStream("seek");
301
+ }
302
+ };
303
+
304
+ function handlePlayPause() {
305
+ if (!playerRef.current) return;
306
+ setPlaying(!playing);
307
+ }
308
+
309
+ function handleStop() {
310
+ if (!playerRef.current) return;
311
+ setPlaying(false);
312
+ seekPlayerTo(0);
313
+ setCurrentTime(0);
314
+ sendToStream("seek");
315
+ sendToStream("pause");
316
+ }
317
+
252
318
  function handleSeek(seconds: number) {
253
- const video = videoRef.current;
254
- if (!video) return;
255
- video.currentTime = Math.max(0, video.currentTime + seconds);
319
+ if (!playerRef.current) return;
320
+ seekPlayerTo(Math.max(0, getPlayerTime() + seconds));
321
+ }
322
+
323
+ function toggleMute() {
324
+ setVolume(volume > 0 ? 0 : 1);
325
+ }
326
+
327
+ function handleVolumeChange(e: React.ChangeEvent<HTMLInputElement>) {
328
+ setVolume(parseFloat(e.target.value));
256
329
  }
257
330
 
258
331
  function handleProgressClick(e: React.MouseEvent<HTMLDivElement>) {
259
- const video = videoRef.current;
260
- if (!video || !duration) return;
332
+ if (!playerRef.current || !duration) return;
261
333
  const rect = e.currentTarget.getBoundingClientRect();
262
334
  const ratio = (e.clientX - rect.left) / rect.width;
263
- video.currentTime = ratio * duration;
335
+ seekPlayerTo(ratio * duration);
264
336
  }
265
337
 
266
338
  function toggleFullscreen() {
@@ -273,7 +345,6 @@ export default function WatchTogetherPage() {
273
345
  }
274
346
  }
275
347
 
276
- // Listen for fullscreen changes (e.g. pressing Esc)
277
348
  useEffect(() => {
278
349
  const handler = () => setIsFullscreen(!!document.fullscreenElement);
279
350
  document.addEventListener("fullscreenchange", handler);
@@ -286,15 +357,20 @@ export default function WatchTogetherPage() {
286
357
  setPlaying(false);
287
358
  setShowPicker(false);
288
359
 
289
- if (connRef.current) {
290
- const stream = connRef.current.stream("watch-room");
291
- stream.send(
292
- JSON.stringify({
293
- type: "source-change",
294
- user: session?.user?.name,
295
- source,
296
- })
297
- );
360
+ if (streamRef.current && roomId) {
361
+ const sess = sessionRef.current;
362
+ try {
363
+ streamRef.current.send(
364
+ JSON.stringify({
365
+ type: "source-change",
366
+ user: sess?.user?.name,
367
+ source,
368
+ })
369
+ );
370
+ } catch {
371
+ // Connection dropped — source changes locally only
372
+ console.warn("Source change not broadcast: connection unavailable");
373
+ }
298
374
  }
299
375
  }
300
376
 
@@ -310,6 +386,22 @@ export default function WatchTogetherPage() {
310
386
  setCustomUrl("");
311
387
  }
312
388
 
389
+ function generateRoom() {
390
+ const code = Math.random().toString(36).slice(2, 8).toUpperCase();
391
+ setRoomId(code);
392
+ }
393
+
394
+ function joinRoom() {
395
+ const code = joinRoomId.trim().toUpperCase();
396
+ if (!code) return;
397
+ if (!/^[A-Z0-9]{6,12}$/.test(code)) {
398
+ setJoinError("Room code must be 6-12 alphanumeric characters.");
399
+ return;
400
+ }
401
+ setJoinError("");
402
+ setRoomId(code);
403
+ }
404
+
313
405
  const formatTime = (t: number) => {
314
406
  if (!t || isNaN(t)) return "0:00";
315
407
  const mins = Math.floor(t / 60);
@@ -320,6 +412,37 @@ export default function WatchTogetherPage() {
320
412
  const isYouTube = activeSource.type === "youtube";
321
413
  const youtubeId = isYouTube ? extractYouTubeId(activeSource.url) : null;
322
414
 
415
+ if (!roomId) {
416
+ return (
417
+ <div className="p-8 h-full flex flex-col items-center justify-center">
418
+ <div className="glass max-w-lg w-full rounded-2xl p-8 space-y-8 text-center">
419
+ <div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-pink-500 to-rose-600 flex items-center justify-center mx-auto shadow-lg shadow-pink-500/20">
420
+ <Video className="w-8 h-8 text-white" />
421
+ </div>
422
+ <div>
423
+ <h1 className="text-2xl font-bold bg-gradient-to-r from-pink-400 to-rose-400 bg-clip-text text-transparent">Watch Together</h1>
424
+ <p className="text-slate-400 mt-2">Create a room to synchronize video playback</p>
425
+ </div>
426
+ <button onClick={generateRoom} className="w-full py-3.5 rounded-xl bg-pink-600 hover:bg-pink-500 font-semibold shadow-xl shadow-pink-600/20 transition-all">Create Watch Room</button>
427
+ <div className="relative">
428
+ <div className="absolute inset-0 flex items-center"><div className="w-full border-t border-slate-700/50"></div></div>
429
+ <div className="relative flex justify-center"><span className="px-4 text-xs text-slate-500 bg-[#162032]">OR JOIN EXISTING</span></div>
430
+ </div>
431
+ {joinError && (
432
+ <div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 flex items-center gap-2 text-red-400 text-sm">
433
+ <AlertCircle className="w-4 h-4 shrink-0" />
434
+ <p>{joinError}</p>
435
+ </div>
436
+ )}
437
+ <div className="flex gap-2">
438
+ <input type="text" value={joinRoomId} onChange={e => { setJoinRoomId(e.target.value); setJoinError(""); }} onKeyDown={e => e.key === "Enter" && joinRoom()} placeholder="Enter Room Code" className="w-full uppercase text-center font-mono tracking-widest px-4 py-3 rounded-xl bg-transparent border-2 border-slate-700 focus:border-pink-500 outline-none transition-colors" />
439
+ </div>
440
+ <button onClick={joinRoom} disabled={!joinRoomId.trim()} className="w-full py-3.5 rounded-xl bg-slate-800 hover:bg-slate-700 font-semibold transition-all disabled:opacity-50">Join Room</button>
441
+ </div>
442
+ </div>
443
+ );
444
+ }
445
+
323
446
  return (
324
447
  <div className="p-8">
325
448
  <div className="flex items-center justify-between mb-8">
@@ -329,8 +452,8 @@ export default function WatchTogetherPage() {
329
452
  </div>
330
453
  <div>
331
454
  <h1 className="text-2xl font-bold">Watch Together</h1>
332
- <p className="text-sm text-slate-500">
333
- Synchronized video playback across users
455
+ <p className="text-sm text-slate-500 font-mono">
456
+ Room: {roomId}
334
457
  </p>
335
458
  </div>
336
459
  </div>
@@ -366,84 +489,88 @@ export default function WatchTogetherPage() {
366
489
  <div className="grid lg:grid-cols-3 gap-8">
367
490
  <div className="lg:col-span-2 space-y-4">
368
491
  {/* Video Player */}
369
- <div ref={playerContainerRef} className="glass rounded-2xl overflow-hidden">
492
+ <div ref={playerContainerRef} className="glass rounded-2xl overflow-hidden shadow-2xl">
370
493
  <div className="aspect-video bg-black relative">
371
- {isYouTube && youtubeId ? (
372
- <iframe
373
- ref={iframeRef}
374
- src={`https://www.youtube.com/embed/${youtubeId}?enablejsapi=1&autoplay=0`}
375
- className="w-full h-full"
376
- allow="autoplay; encrypted-media"
377
- allowFullScreen
378
- title="YouTube Video"
379
- />
380
- ) : (
381
- <video
382
- ref={videoRef}
383
- src={activeSource.url}
384
- className="w-full h-full object-contain"
385
- playsInline
386
- preload="metadata"
387
- />
388
- )}
494
+ <ReactPlayer
495
+ ref={playerRef}
496
+ url={activeSource.url}
497
+ playing={playing}
498
+ volume={volume}
499
+ muted={volume === 0}
500
+ controls={isYouTube}
501
+ width="100%"
502
+ height="100%"
503
+ style={{ position: "absolute", top: 0, left: 0 }}
504
+ onProgress={({ playedSeconds }: { playedSeconds: number }) => setCurrentTime(playedSeconds)}
505
+ onDuration={(d: number) => setDuration(d)}
506
+ onPlay={onPlay}
507
+ onPause={onPause}
508
+ onSeek={onSeeked}
509
+ />
389
510
  </div>
390
511
 
391
512
  {/* Controls */}
392
- {!isYouTube && (
393
- <>
394
- {/* Progress bar */}
513
+ <div className="bg-slate-900/90 backdrop-blur-md">
514
+ {/* Progress bar */}
515
+ <div
516
+ className="h-1.5 bg-slate-800 cursor-pointer group relative"
517
+ onClick={handleProgressClick}
518
+ >
395
519
  <div
396
- className="h-1.5 bg-slate-800 cursor-pointer group relative"
397
- onClick={handleProgressClick}
520
+ className="h-full bg-gradient-to-r from-pink-500 to-rose-500 transition-all relative"
521
+ style={{ width: `${duration ? (currentTime / duration) * 100 : 0}%` }}
398
522
  >
399
- <div
400
- className="h-full bg-gradient-to-r from-pink-500 to-rose-500 transition-all relative"
401
- style={{ width: `${duration ? (currentTime / duration) * 100 : 0}%` }}
402
- >
403
- <div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-white shadow opacity-0 group-hover:opacity-100 transition-opacity" />
404
- </div>
523
+ <div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-white shadow opacity-0 group-hover:opacity-100 transition-opacity" />
405
524
  </div>
406
- <div className="p-4 flex items-center gap-4">
407
- <button onClick={handlePlayPause} disabled={!connected}
408
- className="w-10 h-10 rounded-full bg-white text-black flex items-center justify-center hover:scale-105 transition-transform disabled:opacity-50">
409
- {playing ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5 ml-0.5" />}
410
- </button>
411
- <button onClick={() => handleSeek(10)} disabled={!connected}
412
- className="p-2 rounded-lg hover:bg-slate-800 transition-colors disabled:opacity-50">
413
- <SkipForward className="w-4 h-4" />
414
- </button>
415
- <span className="text-sm text-slate-400 font-mono">
416
- {formatTime(currentTime)} / {formatTime(duration)}
417
- </span>
418
- <div className="flex-1" />
419
- <button onClick={toggleFullscreen}
420
- className="p-2 rounded-lg hover:bg-slate-800 transition-colors" title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}>
421
- {isFullscreen ? <Minimize className="w-4 h-4" /> : <Maximize className="w-4 h-4" />}
525
+ </div>
526
+ <div className="p-3 flex items-center gap-3">
527
+ <button onClick={handlePlayPause} disabled={!connected}
528
+ className="w-8 h-8 rounded-full bg-white text-black flex items-center justify-center hover:scale-105 transition-transform disabled:opacity-50 shrink-0">
529
+ {playing ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4 ml-0.5" />}
530
+ </button>
531
+ <button onClick={handleStop} disabled={!connected}
532
+ className="p-1.5 rounded-lg hover:bg-slate-800 transition-colors disabled:opacity-50 text-slate-300 hover:text-white shrink-0" title="Stop">
533
+ <Square className="w-3.5 h-3.5" />
534
+ </button>
535
+ <button onClick={() => handleSeek(-10)} disabled={!connected}
536
+ className="p-1.5 rounded-lg hover:bg-slate-800 transition-colors disabled:opacity-50 text-slate-300 hover:text-white shrink-0">
537
+ <SkipBack className="w-4 h-4" />
538
+ </button>
539
+ <button onClick={() => handleSeek(10)} disabled={!connected}
540
+ className="p-1.5 rounded-lg hover:bg-slate-800 transition-colors disabled:opacity-50 text-slate-300 hover:text-white shrink-0">
541
+ <SkipForward className="w-4 h-4" />
542
+ </button>
543
+ <span className="text-xs text-slate-300 font-mono w-20 text-center shrink-0">
544
+ {formatTime(currentTime)} / {formatTime(duration)}
545
+ </span>
546
+ <div className="flex items-center gap-2 group w-24 shrink-0 px-2">
547
+ <button onClick={toggleMute} className="text-slate-400 hover:text-white transition-colors">
548
+ {volume > 0 ? <Volume2 className="w-4 h-4" /> : <VolumeX className="w-4 h-4" />}
422
549
  </button>
423
- <div className="flex items-center gap-2">
424
- <Users className="w-4 h-4 text-slate-400" />
425
- <span className="text-sm text-slate-400">{Math.max(viewers.length, 1)} watching</span>
426
- </div>
427
- </div>
428
- </>
429
- )}
430
- {isYouTube && (
431
- <div className="p-4 flex items-center gap-4">
432
- <div className="flex items-center gap-2 text-sm text-slate-400">
433
- <ExternalLink className="w-4 h-4" />
434
- YouTube embed — use player controls to sync
550
+ <input type="range" min="0" max="1" step="0.05" value={volume} onChange={handleVolumeChange} className="w-16 h-1 bg-slate-700 rounded-lg appearance-none cursor-pointer hidden group-hover:block hover:block" />
435
551
  </div>
436
552
  <div className="flex-1" />
553
+ <div className="relative">
554
+ <button onClick={() => setShowQualityMenu(!showQualityMenu)}
555
+ className="px-2 py-1 bg-slate-800 text-[10px] font-bold tracking-wider rounded border border-slate-700 hover:bg-slate-700 text-slate-300 flex items-center gap-1 transition-colors" title="Quality Settings">
556
+ <Settings className="w-3 h-3" /> {quality} <ChevronDown className="w-2.5 h-2.5" />
557
+ </button>
558
+ {showQualityMenu && (
559
+ <div className="absolute bottom-full right-0 mb-1 w-28 rounded-lg bg-slate-800 border border-slate-700 shadow-xl z-50 overflow-hidden">
560
+ {["AUTO", "1080p", "720p", "480p", "360p"].map((q) => (
561
+ <button key={q} onClick={() => { setQuality(q); setShowQualityMenu(false); }}
562
+ className={`w-full text-left px-3 py-2 text-[10px] font-bold tracking-wider hover:bg-slate-700 transition-colors ${quality === q ? "text-pink-400 bg-pink-500/10" : "text-slate-300"
563
+ }`}>{q}</button>
564
+ ))}
565
+ </div>
566
+ )}
567
+ </div>
437
568
  <button onClick={toggleFullscreen}
438
- className="p-2 rounded-lg hover:bg-slate-800 transition-colors" title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}>
569
+ className="p-1.5 rounded-lg hover:bg-slate-800 transition-colors text-slate-300 hover:text-white shrink-0" title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}>
439
570
  {isFullscreen ? <Minimize className="w-4 h-4" /> : <Maximize className="w-4 h-4" />}
440
571
  </button>
441
- <div className="flex items-center gap-2">
442
- <Users className="w-4 h-4 text-slate-400" />
443
- <span className="text-sm text-slate-400">{Math.max(viewers.length, 1)} watching</span>
444
- </div>
445
572
  </div>
446
- )}
573
+ </div>
447
574
  </div>
448
575
 
449
576
  {/* Source Picker */}
@@ -455,25 +582,25 @@ export default function WatchTogetherPage() {
455
582
  </div>
456
583
  <button
457
584
  onClick={() => setShowPicker(!showPicker)}
458
- className="px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs font-medium transition-colors"
585
+ className="px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs font-medium transition-colors border border-slate-700"
459
586
  >
460
587
  {showPicker ? "Close" : "Change Source"}
461
588
  </button>
462
589
  </div>
463
- <div className="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-slate-800/50 border border-slate-700">
590
+ <div className="flex items-center gap-3 px-4 py-3 rounded-xl bg-[#0f172a] border border-slate-800 shadow-inner">
464
591
  <Video className="w-4 h-4 text-pink-400 shrink-0" />
465
- <span className="text-sm text-slate-300 truncate">{activeSource.label}</span>
592
+ <span className="text-sm font-medium text-slate-200 truncate pr-4">{activeSource.label}</span>
466
593
  {activeSource.type === "youtube" && (
467
- <span className="text-[10px] px-1.5 py-0.5 rounded bg-red-500/20 text-red-400 font-medium shrink-0">YouTube</span>
594
+ <span className="text-[10px] px-2 py-0.5 rounded-full bg-red-500/20 text-red-500 font-bold tracking-wide shrink-0 ml-auto">YOUTUBE</span>
468
595
  )}
469
596
  </div>
470
597
 
471
598
  {showPicker && (
472
- <div className="mt-4 space-y-4">
599
+ <div className="mt-5 space-y-5 animate-in fade-in slide-in-from-top-2">
473
600
  {/* Custom URL Input */}
474
601
  <div>
475
- <label className="text-xs text-slate-400 mb-2 block">
476
- Paste a YouTube URL or direct video link
602
+ <label className="text-xs font-semibold text-slate-400 uppercase tracking-widest mb-2 block">
603
+ Custom URL
477
604
  </label>
478
605
  <div className="flex gap-2">
479
606
  <div className="flex-1 relative">
@@ -483,14 +610,14 @@ export default function WatchTogetherPage() {
483
610
  value={customUrl}
484
611
  onChange={(e) => setCustomUrl(e.target.value)}
485
612
  onKeyDown={(e) => e.key === "Enter" && handleCustomUrl()}
486
- placeholder="https://youtube.com/watch?v=... or .mp4 link"
613
+ placeholder="YouTube / Video URL"
487
614
  className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-slate-900/50 border border-slate-700 focus:border-pink-500 focus:ring-1 focus:ring-pink-500 outline-none text-sm transition-colors placeholder:text-slate-600"
488
615
  />
489
616
  </div>
490
617
  <button
491
618
  onClick={handleCustomUrl}
492
619
  disabled={!customUrl.trim()}
493
- className="px-4 py-2.5 rounded-xl bg-pink-600 hover:bg-pink-500 text-sm font-semibold transition-all disabled:opacity-50 disabled:cursor-not-allowed"
620
+ className="px-5 py-2.5 rounded-xl bg-pink-600 hover:bg-pink-500 text-sm font-semibold transition-all shadow-lg shadow-pink-600/20 disabled:opacity-50"
494
621
  >
495
622
  Load
496
623
  </button>
@@ -499,21 +626,21 @@ export default function WatchTogetherPage() {
499
626
 
500
627
  {/* Preset Videos */}
501
628
  <div>
502
- <label className="text-xs text-slate-400 mb-2 block">
503
- Or choose a sample video
629
+ <label className="text-xs font-semibold text-slate-400 uppercase tracking-widest mb-2 block">
630
+ Presets
504
631
  </label>
505
632
  <div className="space-y-2">
506
633
  {PRESET_VIDEOS.map((video) => (
507
634
  <button
508
635
  key={video.url}
509
636
  onClick={() => changeSource(video)}
510
- className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-all text-left ${activeSource.url === video.url
511
- ? "border-pink-500/50 bg-pink-500/10"
512
- : "border-slate-700 hover:border-slate-600 hover:bg-slate-800/50"
637
+ className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl border transition-all text-left ${activeSource.url === video.url
638
+ ? "border-pink-500/50 bg-pink-500/10 shadow-inner"
639
+ : "border-slate-800 bg-[#0f172a] hover:border-slate-700"
513
640
  }`}
514
641
  >
515
642
  <Video className="w-4 h-4 text-slate-400 shrink-0" />
516
- <span className="text-sm flex-1">{video.label}</span>
643
+ <span className="text-sm font-medium flex-1 text-slate-300">{video.label}</span>
517
644
  {activeSource.url === video.url && (
518
645
  <Check className="w-4 h-4 text-pink-400 shrink-0" />
519
646
  )}
@@ -528,40 +655,65 @@ export default function WatchTogetherPage() {
528
655
 
529
656
  <div className="space-y-6">
530
657
  <div className="glass rounded-2xl p-6">
531
- <h3 className="text-sm font-semibold mb-3 text-pink-400">Viewers</h3>
532
- <div className="space-y-2">
533
- <div className="flex items-center gap-2">
534
- <div className="w-6 h-6 rounded-full bg-gradient-to-br from-purple-500 to-cyan-500 flex items-center justify-center text-[10px] font-bold">
535
- {session?.user?.name?.charAt(0) || "?"}
536
- </div>
537
- <span className="text-sm">{session?.user?.name} (you)</span>
538
- </div>
539
- {viewers.filter((v) => v !== session?.user?.name).map((v) => (
540
- <div key={v} className="flex items-center gap-2">
541
- <div className="w-6 h-6 rounded-full bg-gradient-to-br from-pink-500 to-rose-500 flex items-center justify-center text-[10px] font-bold">{v.charAt(0)}</div>
542
- <span className="text-sm">{v}</span>
543
- </div>
544
- ))}
658
+ <h3 className="text-sm font-semibold mb-4 text-pink-400 border-b border-pink-500/20 pb-2">Viewers</h3>
659
+ <div className="space-y-3">
660
+ {viewers.map((v) => {
661
+ const isMe = v === session?.user?.name;
662
+ const state = isMe
663
+ ? { playing, time: currentTime, synced: true }
664
+ : viewerStates[v];
665
+ return (
666
+ <div key={v} className="flex items-center gap-3">
667
+ <div className="w-7 h-7 rounded-full bg-slate-800 border border-slate-700 flex items-center justify-center text-xs font-bold text-slate-300">
668
+ {v.charAt(0)}
669
+ </div>
670
+ <span className="text-sm font-medium text-slate-200 flex-1">{v} {isMe && <span className="text-xs text-slate-500">(you)</span>}</span>
671
+ {state ? (
672
+ state.playing ? (
673
+ <span className="flex items-center gap-1.5 text-[10px] font-medium">
674
+ <span className="w-2 h-2 rounded-full bg-green-400" />
675
+ <span className={state.synced ? "text-green-400" : "text-amber-400"}>{state.synced ? "Synced" : "Syncing..."}</span>
676
+ </span>
677
+ ) : (
678
+ <span className="flex items-center gap-1.5 text-[10px] font-medium">
679
+ <span className="w-2 h-2 rounded-full bg-slate-600" />
680
+ <span className="text-slate-500">Paused</span>
681
+ </span>
682
+ )
683
+ ) : null}
684
+ </div>
685
+ );
686
+ })}
545
687
  </div>
546
688
  </div>
547
689
 
548
690
  <div className="glass rounded-2xl p-6">
549
- <h3 className="text-sm font-semibold mb-3 text-cyan-400">Sync Events</h3>
550
- <div className="space-y-2 max-h-60 overflow-y-auto">
551
- {syncEvents.length === 0 && <p className="text-xs text-slate-500">No sync events yet. Press play!</p>}
552
- {syncEvents.map((event, i) => <div key={i} className="text-xs text-slate-400">{event}</div>)}
691
+ <h3 className="text-sm font-semibold mb-4 text-cyan-400 border-b border-cyan-500/20 pb-2">Sync Events</h3>
692
+ <div className="space-y-2 max-h-[200px] overflow-y-auto pr-2">
693
+ {syncEvents.length === 0 && <p className="text-xs text-slate-500 italic">No sync events yet. Press play!</p>}
694
+ {syncEvents.slice(0, 5).map((event, i) => <div key={i} className="text-xs text-slate-400 py-1 border-b border-slate-800/50 last:border-0">{event}</div>)}
553
695
  </div>
554
696
  </div>
555
697
 
556
- {/* Info card */}
557
- <div className="glass rounded-xl p-5 border-l-4 border-pink-500">
558
- <h3 className="text-sm font-semibold text-pink-400 mb-1">
559
- How It Works
560
- </h3>
561
- <p className="text-xs text-slate-400 leading-relaxed">
562
- Play, pause, and seek events are broadcast via Pulse streams to all viewers.
563
- The relay ensures every participant stays in sync. Try opening this in two tabs!
564
- </p>
698
+ <div className="glass rounded-2xl p-4 bg-black/40">
699
+ <div className="flex justify-between mb-2">
700
+ <div>
701
+ <p className="text-[10px] text-slate-500 uppercase tracking-widest">Message RTT</p>
702
+ <p className="text-sm font-mono text-emerald-400">{metrics.rttMs > 0 ? `${metrics.rttMs}ms` : "—"}</p>
703
+ </div>
704
+ <div className="text-center">
705
+ <p className="text-[10px] text-slate-500 uppercase tracking-widest">Sync Msgs</p>
706
+ <p className="text-sm font-mono text-cyan-400">{metrics.msgCount}</p>
707
+ </div>
708
+ <div className="text-right">
709
+ <p className="text-[10px] text-slate-500 uppercase tracking-widest">Viewers</p>
710
+ <p className="text-sm font-mono text-pink-400">{viewers.length}</p>
711
+ </div>
712
+ </div>
713
+ <div className="text-center">
714
+ <p className="text-[10px] text-slate-500 uppercase tracking-widest">Quality</p>
715
+ <p className="text-sm font-mono text-amber-400">{quality}</p>
716
+ </div>
565
717
  </div>
566
718
  </div>
567
719
  </div>
@@ -569,3 +721,4 @@ export default function WatchTogetherPage() {
569
721
  </div>
570
722
  );
571
723
  }
724
+