@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,724 @@
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, Play, Pause, SkipForward, Users, Shield, Wifi, WifiOff, Loader2,
8
+ Link2, ExternalLink, ListVideo, Check, Maximize, Minimize, Settings, Volume2, VolumeX, SkipBack,
9
+ AlertCircle, ChevronDown, Square
10
+ } from "lucide-react";
11
+ import type { PulseConnection } from "@sansavision/pulse-sdk";
12
+ import dynamic from "next/dynamic";
13
+
14
+ const ReactPlayer = dynamic(() => import("react-player"), { ssr: false });
15
+
16
+ interface VideoSource {
17
+ label: string;
18
+ url: string;
19
+ type: "youtube" | "direct" | "sample";
20
+ }
21
+
22
+ const PRESET_VIDEOS: VideoSource[] = [
23
+ {
24
+ label: "Big Buck Bunny (Sample)",
25
+ url: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
26
+ type: "sample",
27
+ },
28
+ {
29
+ label: "Sintel Trailer (Sample)",
30
+ url: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4",
31
+ type: "sample",
32
+ },
33
+ {
34
+ label: "Elephant Dream (Sample)",
35
+ url: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4",
36
+ type: "sample",
37
+ },
38
+ ];
39
+
40
+ function extractYouTubeId(url: string): string | null {
41
+ const patterns = [
42
+ /youtu\.be\/([a-zA-Z0-9_-]{11})/,
43
+ /youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/,
44
+ /youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/,
45
+ /youtube\.com\/v\/([a-zA-Z0-9_-]{11})/,
46
+ ];
47
+ for (const pattern of patterns) {
48
+ const match = url.match(pattern);
49
+ if (match) return match[1];
50
+ }
51
+ return null;
52
+ }
53
+
54
+ function getVideoType(url: string): "youtube" | "direct" {
55
+ return extractYouTubeId(url) ? "youtube" : "direct";
56
+ }
57
+
58
+ export default function WatchTogetherPage() {
59
+ const { data: session } = useSession();
60
+ const [connected, setConnected] = useState(false);
61
+ const [authUser, setAuthUser] = useState<{ id: string; claims: Record<string, string> } | null>(null);
62
+ const [connecting, setConnecting] = useState(true);
63
+ const [roomId, setRoomId] = useState("");
64
+ const [joinRoomId, setJoinRoomId] = useState("");
65
+ const [joinError, setJoinError] = useState("");
66
+ const [playing, setPlaying] = useState(false);
67
+ const [currentTime, setCurrentTime] = useState(0);
68
+ const [duration, setDuration] = useState(0);
69
+ const [viewers, setViewers] = useState<string[]>([]);
70
+ const [syncEvents, setSyncEvents] = useState<string[]>([]);
71
+ const [activeSource, setActiveSource] = useState<VideoSource>(PRESET_VIDEOS[0]);
72
+ const [customUrl, setCustomUrl] = useState("");
73
+ const [showPicker, setShowPicker] = useState(false);
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
+
80
+ const connRef = useRef<PulseConnection | null>(null);
81
+ const streamRef = useRef<ReturnType<PulseConnection["stream"]> | null>(null);
82
+ const playerRef = useRef<any>(null);
83
+ const playerContainerRef = useRef<HTMLDivElement>(null);
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]);
97
+
98
+ useEffect(() => {
99
+ if (!session) return;
100
+ let cancelled = false;
101
+
102
+ async function init() {
103
+ try {
104
+ const connection = await connectWithAuth();
105
+ if (cancelled) return;
106
+
107
+ connRef.current = connection;
108
+ setConnected(true);
109
+ setConnecting(false);
110
+
111
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
112
+ const user = (connection as any).user;
113
+ if (user) setAuthUser(user);
114
+
115
+ connection.on("disconnect", () => setConnected(false));
116
+ connection.on("reconnected", () => setConnected(true));
117
+ } catch (err) {
118
+ console.error("Failed to connect:", err);
119
+ setConnecting(false);
120
+ }
121
+ }
122
+
123
+ init();
124
+ return () => {
125
+ cancelled = true;
126
+ connRef.current?.disconnect();
127
+ };
128
+ }, [session]);
129
+
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
+
138
+ useEffect(() => {
139
+ if (!roomId || !connected || !connRef.current) return;
140
+ const sess = sessionRef.current;
141
+ if (!sess) return;
142
+
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);
236
+ }
237
+ };
238
+
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);
256
+
257
+ return () => {
258
+ clearInterval(heartbeatInterval);
259
+ stream.close();
260
+ streamRef.current = null;
261
+ };
262
+ }, [roomId, connected]);
263
+
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
279
+ }
280
+ }
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
+
318
+ function handleSeek(seconds: number) {
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));
329
+ }
330
+
331
+ function handleProgressClick(e: React.MouseEvent<HTMLDivElement>) {
332
+ if (!playerRef.current || !duration) return;
333
+ const rect = e.currentTarget.getBoundingClientRect();
334
+ const ratio = (e.clientX - rect.left) / rect.width;
335
+ seekPlayerTo(ratio * duration);
336
+ }
337
+
338
+ function toggleFullscreen() {
339
+ const container = playerContainerRef.current;
340
+ if (!container) return;
341
+ if (!document.fullscreenElement) {
342
+ container.requestFullscreen().then(() => setIsFullscreen(true)).catch(() => { });
343
+ } else {
344
+ document.exitFullscreen().then(() => setIsFullscreen(false)).catch(() => { });
345
+ }
346
+ }
347
+
348
+ useEffect(() => {
349
+ const handler = () => setIsFullscreen(!!document.fullscreenElement);
350
+ document.addEventListener("fullscreenchange", handler);
351
+ return () => document.removeEventListener("fullscreenchange", handler);
352
+ }, []);
353
+
354
+ function changeSource(source: VideoSource) {
355
+ setActiveSource(source);
356
+ setCurrentTime(0);
357
+ setPlaying(false);
358
+ setShowPicker(false);
359
+
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
+ }
374
+ }
375
+ }
376
+
377
+ function handleCustomUrl() {
378
+ if (!customUrl.trim()) return;
379
+ const url = customUrl.trim();
380
+ const type = getVideoType(url);
381
+ const label = type === "youtube"
382
+ ? `YouTube: ${extractYouTubeId(url)}`
383
+ : url.split("/").pop()?.split("?")[0] || "Custom Video";
384
+
385
+ changeSource({ label, url, type });
386
+ setCustomUrl("");
387
+ }
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
+
405
+ const formatTime = (t: number) => {
406
+ if (!t || isNaN(t)) return "0:00";
407
+ const mins = Math.floor(t / 60);
408
+ const secs = Math.floor(t % 60);
409
+ return `${mins}:${secs.toString().padStart(2, "0")}`;
410
+ };
411
+
412
+ const isYouTube = activeSource.type === "youtube";
413
+ const youtubeId = isYouTube ? extractYouTubeId(activeSource.url) : null;
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
+
446
+ return (
447
+ <div className="p-8">
448
+ <div className="flex items-center justify-between mb-8">
449
+ <div className="flex items-center gap-3">
450
+ <div className="w-12 h-12 rounded-xl bg-gradient-to-br from-pink-500 to-rose-600 flex items-center justify-center">
451
+ <Video className="w-6 h-6 text-white" />
452
+ </div>
453
+ <div>
454
+ <h1 className="text-2xl font-bold">Watch Together</h1>
455
+ <p className="text-sm text-slate-500 font-mono">
456
+ Room: {roomId}
457
+ </p>
458
+ </div>
459
+ </div>
460
+ <div className="flex items-center gap-3">
461
+ {authUser && (
462
+ <div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-green-500/10 border border-green-500/20">
463
+ <Shield className="w-3.5 h-3.5 text-green-400" />
464
+ <span className="text-xs text-green-400">
465
+ {authUser.claims.name || authUser.id}
466
+ </span>
467
+ </div>
468
+ )}
469
+ {connected ? (
470
+ <div className="flex items-center gap-2">
471
+ <Wifi className="w-4 h-4 text-green-400" />
472
+ <span className="text-xs text-green-400">Connected</span>
473
+ </div>
474
+ ) : (
475
+ <div className="flex items-center gap-2">
476
+ <WifiOff className="w-4 h-4 text-red-400" />
477
+ <span className="text-xs text-red-400">Disconnected</span>
478
+ </div>
479
+ )}
480
+ </div>
481
+ </div>
482
+
483
+ {connecting ? (
484
+ <div className="flex items-center justify-center py-20">
485
+ <Loader2 className="w-6 h-6 animate-spin text-purple-500 mr-2" />
486
+ <span className="text-slate-400">Connecting...</span>
487
+ </div>
488
+ ) : (
489
+ <div className="grid lg:grid-cols-3 gap-8">
490
+ <div className="lg:col-span-2 space-y-4">
491
+ {/* Video Player */}
492
+ <div ref={playerContainerRef} className="glass rounded-2xl overflow-hidden shadow-2xl">
493
+ <div className="aspect-video bg-black relative">
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
+ />
510
+ </div>
511
+
512
+ {/* Controls */}
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
+ >
519
+ <div
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}%` }}
522
+ >
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" />
524
+ </div>
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" />}
549
+ </button>
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" />
551
+ </div>
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>
568
+ <button onClick={toggleFullscreen}
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"}>
570
+ {isFullscreen ? <Minimize className="w-4 h-4" /> : <Maximize className="w-4 h-4" />}
571
+ </button>
572
+ </div>
573
+ </div>
574
+ </div>
575
+
576
+ {/* Source Picker */}
577
+ <div className="glass rounded-2xl p-5">
578
+ <div className="flex items-center justify-between mb-4">
579
+ <div className="flex items-center gap-2">
580
+ <ListVideo className="w-4 h-4 text-pink-400" />
581
+ <h3 className="text-sm font-semibold text-pink-400">Now Playing</h3>
582
+ </div>
583
+ <button
584
+ onClick={() => setShowPicker(!showPicker)}
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"
586
+ >
587
+ {showPicker ? "Close" : "Change Source"}
588
+ </button>
589
+ </div>
590
+ <div className="flex items-center gap-3 px-4 py-3 rounded-xl bg-[#0f172a] border border-slate-800 shadow-inner">
591
+ <Video className="w-4 h-4 text-pink-400 shrink-0" />
592
+ <span className="text-sm font-medium text-slate-200 truncate pr-4">{activeSource.label}</span>
593
+ {activeSource.type === "youtube" && (
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>
595
+ )}
596
+ </div>
597
+
598
+ {showPicker && (
599
+ <div className="mt-5 space-y-5 animate-in fade-in slide-in-from-top-2">
600
+ {/* Custom URL Input */}
601
+ <div>
602
+ <label className="text-xs font-semibold text-slate-400 uppercase tracking-widest mb-2 block">
603
+ Custom URL
604
+ </label>
605
+ <div className="flex gap-2">
606
+ <div className="flex-1 relative">
607
+ <Link2 className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
608
+ <input
609
+ type="text"
610
+ value={customUrl}
611
+ onChange={(e) => setCustomUrl(e.target.value)}
612
+ onKeyDown={(e) => e.key === "Enter" && handleCustomUrl()}
613
+ placeholder="YouTube / Video URL"
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"
615
+ />
616
+ </div>
617
+ <button
618
+ onClick={handleCustomUrl}
619
+ disabled={!customUrl.trim()}
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"
621
+ >
622
+ Load
623
+ </button>
624
+ </div>
625
+ </div>
626
+
627
+ {/* Preset Videos */}
628
+ <div>
629
+ <label className="text-xs font-semibold text-slate-400 uppercase tracking-widest mb-2 block">
630
+ Presets
631
+ </label>
632
+ <div className="space-y-2">
633
+ {PRESET_VIDEOS.map((video) => (
634
+ <button
635
+ key={video.url}
636
+ onClick={() => changeSource(video)}
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"
640
+ }`}
641
+ >
642
+ <Video className="w-4 h-4 text-slate-400 shrink-0" />
643
+ <span className="text-sm font-medium flex-1 text-slate-300">{video.label}</span>
644
+ {activeSource.url === video.url && (
645
+ <Check className="w-4 h-4 text-pink-400 shrink-0" />
646
+ )}
647
+ </button>
648
+ ))}
649
+ </div>
650
+ </div>
651
+ </div>
652
+ )}
653
+ </div>
654
+ </div>
655
+
656
+ <div className="space-y-6">
657
+ <div className="glass rounded-2xl p-6">
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
+ })}
687
+ </div>
688
+ </div>
689
+
690
+ <div className="glass rounded-2xl p-6">
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>)}
695
+ </div>
696
+ </div>
697
+
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>
717
+ </div>
718
+ </div>
719
+ </div>
720
+ )}
721
+ </div>
722
+ );
723
+ }
724
+