@sansavision/create-pulse 0.4.3 → 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 (102) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +1 -1
  3. package/package.json +3 -3
  4. package/templates/aurora-auth-node-demo/README.md +43 -0
  5. package/templates/aurora-auth-node-demo/aurora.config.ts +15 -0
  6. package/templates/aurora-auth-node-demo/bun.lock +679 -0
  7. package/templates/aurora-auth-node-demo/drizzle.config.ts +9 -0
  8. package/templates/aurora-auth-node-demo/package.json +39 -0
  9. package/templates/aurora-auth-node-demo/postcss.config.mjs +7 -0
  10. package/templates/aurora-auth-node-demo/server.mjs +46 -0
  11. package/templates/aurora-auth-node-demo/src/actions/createMessage.action.server.ts +31 -0
  12. package/templates/aurora-auth-node-demo/src/aurora.auth.ts +65 -0
  13. package/templates/aurora-auth-node-demo/src/lib/auth-client.ts +30 -0
  14. package/templates/aurora-auth-node-demo/src/lib/auth.server.ts +11 -0
  15. package/templates/aurora-auth-node-demo/src/lib/auth.ts +30 -0
  16. package/templates/aurora-auth-node-demo/src/lib/db.ts +6 -0
  17. package/templates/aurora-auth-node-demo/src/lib/pulse.ts +45 -0
  18. package/templates/aurora-auth-node-demo/src/lib/schema.ts +107 -0
  19. package/templates/aurora-auth-node-demo/src/queries/listMessages.server.ts +25 -0
  20. package/templates/aurora-auth-node-demo/src/routes/api/auth/[...slug]/handler.ts +14 -0
  21. package/templates/aurora-auth-node-demo/src/routes/api/pulse/verify/handler.ts +55 -0
  22. package/templates/aurora-auth-node-demo/src/routes/auth/sign-in/page.client.tsx +132 -0
  23. package/templates/aurora-auth-node-demo/src/routes/auth/sign-in/page.tsx +5 -0
  24. package/templates/aurora-auth-node-demo/src/routes/auth/sign-up/page.client.tsx +154 -0
  25. package/templates/aurora-auth-node-demo/src/routes/auth/sign-up/page.tsx +5 -0
  26. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/arena-game/page.client.tsx +640 -0
  27. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/arena-game/page.tsx +5 -0
  28. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/chat/page.client.tsx +349 -0
  29. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/chat/page.tsx +5 -0
  30. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/encrypted-chat/page.client.tsx +472 -0
  31. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/encrypted-chat/page.tsx +5 -0
  32. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/game-sync/page.client.tsx +375 -0
  33. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/game-sync/page.tsx +5 -0
  34. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/queues/page.client.tsx +423 -0
  35. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/queues/page.tsx +5 -0
  36. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/video-call/page.client.tsx +840 -0
  37. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/video-call/page.tsx +5 -0
  38. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/watch-together/page.client.tsx +722 -0
  39. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/watch-together/page.tsx +5 -0
  40. package/templates/aurora-auth-node-demo/src/routes/dashboard/layout.client.tsx +113 -0
  41. package/templates/aurora-auth-node-demo/src/routes/dashboard/layout.tsx +5 -0
  42. package/templates/aurora-auth-node-demo/src/routes/dashboard/page.client.tsx +195 -0
  43. package/templates/aurora-auth-node-demo/src/routes/dashboard/page.tsx +5 -0
  44. package/templates/aurora-auth-node-demo/src/routes/favicon.ico +0 -0
  45. package/templates/aurora-auth-node-demo/src/routes/layout.tsx +18 -0
  46. package/templates/aurora-auth-node-demo/src/routes/page.client.tsx +263 -0
  47. package/templates/aurora-auth-node-demo/src/routes/page.tsx +5 -0
  48. package/templates/aurora-auth-node-demo/src/styles/app.css +96 -0
  49. package/templates/aurora-auth-node-demo/tsconfig.json +27 -0
  50. package/templates/aurora-auth-node-demo/tsconfig.tsbuildinfo +1 -0
  51. package/templates/nextjs-auth-demo/README.md +1 -1
  52. package/templates/nextjs-auth-demo/next-env.d.ts +1 -1
  53. package/templates/nextjs-auth-demo/package.json +8 -7
  54. package/templates/nextjs-auth-demo/src/app/dashboard/demos/arena-game/page.tsx +640 -0
  55. package/templates/nextjs-auth-demo/src/app/dashboard/demos/chat/page.tsx +124 -23
  56. package/templates/nextjs-auth-demo/src/app/dashboard/demos/encrypted-chat/page.tsx +350 -76
  57. package/templates/nextjs-auth-demo/src/app/dashboard/demos/game-sync/page.tsx +232 -49
  58. package/templates/nextjs-auth-demo/src/app/dashboard/demos/queues/page.tsx +213 -87
  59. package/templates/nextjs-auth-demo/src/app/dashboard/demos/video-call/page.tsx +840 -0
  60. package/templates/nextjs-auth-demo/src/app/dashboard/demos/watch-together/page.tsx +589 -123
  61. package/templates/nextjs-auth-demo/src/app/dashboard/layout.tsx +4 -0
  62. package/templates/nextjs-auth-demo/src/app/dashboard/page.tsx +53 -5
  63. package/templates/nextjs-auth-demo/src/app/layout.tsx +1 -1
  64. package/templates/nextjs-auth-node-demo/.env.example +10 -0
  65. package/templates/nextjs-auth-node-demo/Dockerfile +19 -0
  66. package/templates/nextjs-auth-node-demo/README.md +159 -0
  67. package/templates/nextjs-auth-node-demo/_gitignore +33 -0
  68. package/templates/nextjs-auth-node-demo/drizzle.config.ts +10 -0
  69. package/templates/nextjs-auth-node-demo/eslint.config.mjs +18 -0
  70. package/templates/nextjs-auth-node-demo/next-env.d.ts +6 -0
  71. package/templates/nextjs-auth-node-demo/next.config.ts +7 -0
  72. package/templates/nextjs-auth-node-demo/package.json +38 -0
  73. package/templates/nextjs-auth-node-demo/postcss.config.mjs +7 -0
  74. package/templates/nextjs-auth-node-demo/public/file.svg +1 -0
  75. package/templates/nextjs-auth-node-demo/public/globe.svg +1 -0
  76. package/templates/nextjs-auth-node-demo/public/next.svg +1 -0
  77. package/templates/nextjs-auth-node-demo/public/vercel.svg +1 -0
  78. package/templates/nextjs-auth-node-demo/public/window.svg +1 -0
  79. package/templates/nextjs-auth-node-demo/server.mjs +45 -0
  80. package/templates/nextjs-auth-node-demo/src/app/api/auth/[...all]/route.ts +4 -0
  81. package/templates/nextjs-auth-node-demo/src/app/api/pulse/verify/route.ts +54 -0
  82. package/templates/nextjs-auth-node-demo/src/app/auth/sign-in/page.tsx +131 -0
  83. package/templates/nextjs-auth-node-demo/src/app/auth/sign-up/page.tsx +153 -0
  84. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/arena-game/page.tsx +640 -0
  85. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/chat/page.tsx +349 -0
  86. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/encrypted-chat/page.tsx +472 -0
  87. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/game-sync/page.tsx +375 -0
  88. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/queues/page.tsx +423 -0
  89. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/video-call/page.tsx +840 -0
  90. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/watch-together/page.tsx +724 -0
  91. package/templates/nextjs-auth-node-demo/src/app/dashboard/layout.tsx +113 -0
  92. package/templates/nextjs-auth-node-demo/src/app/dashboard/page.tsx +195 -0
  93. package/templates/nextjs-auth-node-demo/src/app/favicon.ico +0 -0
  94. package/templates/nextjs-auth-node-demo/src/app/globals.css +96 -0
  95. package/templates/nextjs-auth-node-demo/src/app/layout.tsx +27 -0
  96. package/templates/nextjs-auth-node-demo/src/app/page.tsx +254 -0
  97. package/templates/nextjs-auth-node-demo/src/lib/auth-client.ts +15 -0
  98. package/templates/nextjs-auth-node-demo/src/lib/auth.ts +14 -0
  99. package/templates/nextjs-auth-node-demo/src/lib/db.ts +6 -0
  100. package/templates/nextjs-auth-node-demo/src/lib/pulse.ts +45 -0
  101. package/templates/nextjs-auth-node-demo/src/lib/schema.ts +107 -0
  102. package/templates/nextjs-auth-node-demo/tsconfig.json +34 -0
@@ -1,32 +1,99 @@
1
1
  "use client";
2
2
 
3
- import { useState, useEffect, useRef } from "react";
3
+ 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,
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
16
10
  } from "lucide-react";
17
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
+ }
18
57
 
19
58
  export default function WatchTogetherPage() {
20
59
  const { data: session } = useSession();
21
60
  const [connected, setConnected] = useState(false);
22
61
  const [authUser, setAuthUser] = useState<{ id: string; claims: Record<string, string> } | null>(null);
23
62
  const [connecting, setConnecting] = useState(true);
63
+ const [roomId, setRoomId] = useState("");
64
+ const [joinRoomId, setJoinRoomId] = useState("");
65
+ const [joinError, setJoinError] = useState("");
24
66
  const [playing, setPlaying] = useState(false);
25
67
  const [currentTime, setCurrentTime] = useState(0);
68
+ const [duration, setDuration] = useState(0);
26
69
  const [viewers, setViewers] = useState<string[]>([]);
27
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
+
28
80
  const connRef = useRef<PulseConnection | null>(null);
29
- const intervalRef = useRef<ReturnType<typeof setInterval> | 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]);
30
97
 
31
98
  useEffect(() => {
32
99
  if (!session) return;
@@ -45,47 +112,6 @@ export default function WatchTogetherPage() {
45
112
  const user = (connection as any).user;
46
113
  if (user) setAuthUser(user);
47
114
 
48
- const stream = connection.stream("watch-room");
49
-
50
- stream.send(
51
- JSON.stringify({ type: "viewer-join", name: session!.user.name })
52
- );
53
-
54
- stream.on("data", (data: Uint8Array) => {
55
- try {
56
- const msg = JSON.parse(new TextDecoder().decode(data));
57
- if (msg.type === "play") {
58
- setPlaying(true);
59
- setSyncEvents((prev) => [
60
- `${msg.user} pressed play at ${msg.time?.toFixed(1)}s`,
61
- ...prev.slice(0, 9),
62
- ]);
63
- } else if (msg.type === "pause") {
64
- setPlaying(false);
65
- setSyncEvents((prev) => [
66
- `${msg.user} paused at ${msg.time?.toFixed(1)}s`,
67
- ...prev.slice(0, 9),
68
- ]);
69
- } else if (msg.type === "seek") {
70
- setCurrentTime(msg.time || 0);
71
- setSyncEvents((prev) => [
72
- `${msg.user} seeked to ${msg.time?.toFixed(1)}s`,
73
- ...prev.slice(0, 9),
74
- ]);
75
- } else if (msg.type === "viewer-join") {
76
- setViewers((prev) =>
77
- prev.includes(msg.name) ? prev : [...prev, msg.name]
78
- );
79
- setSyncEvents((prev) => [
80
- `${msg.name} joined the watch party`,
81
- ...prev.slice(0, 9),
82
- ]);
83
- }
84
- } catch {
85
- /* ignore */
86
- }
87
- });
88
-
89
115
  connection.on("disconnect", () => setConnected(false));
90
116
  connection.on("reconnected", () => setConnected(true));
91
117
  } catch (err) {
@@ -98,53 +124,325 @@ export default function WatchTogetherPage() {
98
124
  return () => {
99
125
  cancelled = true;
100
126
  connRef.current?.disconnect();
101
- if (intervalRef.current) clearInterval(intervalRef.current);
102
127
  };
103
128
  }, [session]);
104
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
+
105
138
  useEffect(() => {
106
- if (playing) {
107
- intervalRef.current = setInterval(
108
- () => setCurrentTime((t) => t + 0.1),
109
- 100
110
- );
111
- } else {
112
- if (intervalRef.current) clearInterval(intervalRef.current);
113
- }
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
+
114
257
  return () => {
115
- if (intervalRef.current) clearInterval(intervalRef.current);
258
+ clearInterval(heartbeatInterval);
259
+ stream.close();
260
+ streamRef.current = null;
116
261
  };
117
- }, [playing]);
118
-
119
- function sendAction(type: string) {
120
- if (!connRef.current) return;
121
- const stream = connRef.current.stream("watch-room");
122
- stream.send(
123
- JSON.stringify({
124
- type,
125
- user: session?.user?.name,
126
- time: currentTime,
127
- })
128
- );
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
+ }
129
280
  }
130
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
+
131
304
  function handlePlayPause() {
132
- const action = playing ? "pause" : "play";
133
- sendAction(action);
305
+ if (!playerRef.current) return;
134
306
  setPlaying(!playing);
135
307
  }
136
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
+
137
318
  function handleSeek(seconds: number) {
138
- setCurrentTime((prev) => Math.max(0, prev + seconds));
139
- sendAction("seek");
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);
140
403
  }
141
404
 
142
405
  const formatTime = (t: number) => {
406
+ if (!t || isNaN(t)) return "0:00";
143
407
  const mins = Math.floor(t / 60);
144
408
  const secs = Math.floor(t % 60);
145
409
  return `${mins}:${secs.toString().padStart(2, "0")}`;
146
410
  };
147
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
+
148
446
  return (
149
447
  <div className="p-8">
150
448
  <div className="flex items-center justify-between mb-8">
@@ -154,8 +452,8 @@ export default function WatchTogetherPage() {
154
452
  </div>
155
453
  <div>
156
454
  <h1 className="text-2xl font-bold">Watch Together</h1>
157
- <p className="text-sm text-slate-500">
158
- Synchronized video playback across users
455
+ <p className="text-sm text-slate-500 font-mono">
456
+ Room: {roomId}
159
457
  </p>
160
458
  </div>
161
459
  </div>
@@ -189,65 +487,232 @@ export default function WatchTogetherPage() {
189
487
  </div>
190
488
  ) : (
191
489
  <div className="grid lg:grid-cols-3 gap-8">
192
- <div className="lg:col-span-2">
193
- <div className="glass rounded-2xl overflow-hidden">
194
- <div className="aspect-video bg-gradient-to-br from-slate-900 to-slate-800 flex items-center justify-center relative">
195
- <div className="text-center">
196
- <Video className="w-16 h-16 text-slate-600 mx-auto mb-4" />
197
- <p className="text-slate-500 text-sm">Simulated Video Player</p>
198
- <p className="text-3xl font-mono text-white mt-2">{formatTime(currentTime)}</p>
199
- </div>
200
- <div className="absolute bottom-0 left-0 right-0 h-1 bg-slate-700">
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
+ >
201
519
  <div
202
- className="h-full bg-gradient-to-r from-pink-500 to-rose-500 transition-all"
203
- style={{ width: `${Math.min((currentTime / 300) * 100, 100)}%` }}
204
- />
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>
205
572
  </div>
206
573
  </div>
574
+ </div>
207
575
 
208
- <div className="p-4 flex items-center gap-4">
209
- <button onClick={handlePlayPause} disabled={!connected}
210
- className="w-10 h-10 rounded-full bg-white text-black flex items-center justify-center hover:scale-105 transition-transform disabled:opacity-50">
211
- {playing ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5 ml-0.5" />}
212
- </button>
213
- <button onClick={() => handleSeek(10)} disabled={!connected}
214
- className="p-2 rounded-lg hover:bg-slate-800 transition-colors disabled:opacity-50">
215
- <SkipForward className="w-4 h-4" />
216
- </button>
217
- <span className="text-sm text-slate-400 font-mono">{formatTime(currentTime)} / 5:00</span>
218
- <div className="flex-1" />
576
+ {/* Source Picker */}
577
+ <div className="glass rounded-2xl p-5">
578
+ <div className="flex items-center justify-between mb-4">
219
579
  <div className="flex items-center gap-2">
220
- <Users className="w-4 h-4 text-slate-400" />
221
- <span className="text-sm text-slate-400">{Math.max(viewers.length, 1)} watching</span>
580
+ <ListVideo className="w-4 h-4 text-pink-400" />
581
+ <h3 className="text-sm font-semibold text-pink-400">Now Playing</h3>
222
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>
223
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
+ )}
224
653
  </div>
225
654
  </div>
226
655
 
227
656
  <div className="space-y-6">
228
657
  <div className="glass rounded-2xl p-6">
229
- <h3 className="text-sm font-semibold mb-3 text-pink-400">Viewers</h3>
230
- <div className="space-y-2">
231
- <div className="flex items-center gap-2">
232
- <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">
233
- {session?.user?.name?.charAt(0) || "?"}
234
- </div>
235
- <span className="text-sm">{session?.user?.name} (you)</span>
236
- </div>
237
- {viewers.filter((v) => v !== session?.user?.name).map((v) => (
238
- <div key={v} className="flex items-center gap-2">
239
- <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>
240
- <span className="text-sm">{v}</span>
241
- </div>
242
- ))}
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
+ })}
243
687
  </div>
244
688
  </div>
245
689
 
246
690
  <div className="glass rounded-2xl p-6">
247
- <h3 className="text-sm font-semibold mb-3 text-cyan-400">Sync Events</h3>
248
- <div className="space-y-2 max-h-60 overflow-y-auto">
249
- {syncEvents.length === 0 && <p className="text-xs text-slate-500">No sync events yet. Press play!</p>}
250
- {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>)}
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>
251
716
  </div>
252
717
  </div>
253
718
  </div>
@@ -256,3 +721,4 @@ export default function WatchTogetherPage() {
256
721
  </div>
257
722
  );
258
723
  }
724
+