@sansavision/create-pulse 0.4.2 → 0.4.4

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.
@@ -1,6 +1,6 @@
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 {
@@ -13,9 +13,57 @@ import {
13
13
  Wifi,
14
14
  WifiOff,
15
15
  Loader2,
16
+ Link2,
17
+ ExternalLink,
18
+ ListVideo,
19
+ Check,
20
+ Maximize,
21
+ Minimize,
16
22
  } from "lucide-react";
17
23
  import type { PulseConnection } from "@sansavision/pulse-sdk";
18
24
 
25
+ interface VideoSource {
26
+ label: string;
27
+ url: string;
28
+ type: "youtube" | "direct" | "sample";
29
+ }
30
+
31
+ const PRESET_VIDEOS: VideoSource[] = [
32
+ {
33
+ label: "Big Buck Bunny (Sample)",
34
+ url: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
35
+ type: "sample",
36
+ },
37
+ {
38
+ label: "Sintel Trailer (Sample)",
39
+ url: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4",
40
+ type: "sample",
41
+ },
42
+ {
43
+ label: "Elephant Dream (Sample)",
44
+ url: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4",
45
+ type: "sample",
46
+ },
47
+ ];
48
+
49
+ function extractYouTubeId(url: string): string | null {
50
+ const patterns = [
51
+ /youtu\.be\/([a-zA-Z0-9_-]{11})/,
52
+ /youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/,
53
+ /youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/,
54
+ /youtube\.com\/v\/([a-zA-Z0-9_-]{11})/,
55
+ ];
56
+ for (const pattern of patterns) {
57
+ const match = url.match(pattern);
58
+ if (match) return match[1];
59
+ }
60
+ return null;
61
+ }
62
+
63
+ function getVideoType(url: string): "youtube" | "direct" {
64
+ return extractYouTubeId(url) ? "youtube" : "direct";
65
+ }
66
+
19
67
  export default function WatchTogetherPage() {
20
68
  const { data: session } = useSession();
21
69
  const [connected, setConnected] = useState(false);
@@ -23,11 +71,20 @@ export default function WatchTogetherPage() {
23
71
  const [connecting, setConnecting] = useState(true);
24
72
  const [playing, setPlaying] = useState(false);
25
73
  const [currentTime, setCurrentTime] = useState(0);
74
+ const [duration, setDuration] = useState(0);
26
75
  const [viewers, setViewers] = useState<string[]>([]);
27
76
  const [syncEvents, setSyncEvents] = useState<string[]>([]);
77
+ const [activeSource, setActiveSource] = useState<VideoSource>(PRESET_VIDEOS[0]);
78
+ const [customUrl, setCustomUrl] = useState("");
79
+ const [showPicker, setShowPicker] = useState(false);
80
+ const [isFullscreen, setIsFullscreen] = useState(false);
28
81
  const connRef = useRef<PulseConnection | null>(null);
29
- const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
82
+ const videoRef = useRef<HTMLVideoElement>(null);
83
+ const iframeRef = useRef<HTMLIFrameElement>(null);
84
+ const playerContainerRef = useRef<HTMLDivElement>(null);
85
+ const ignoreSyncRef = useRef(false);
30
86
 
87
+ // Connect to Pulse relay
31
88
  useEffect(() => {
32
89
  if (!session) return;
33
90
  let cancelled = false;
@@ -56,21 +113,37 @@ export default function WatchTogetherPage() {
56
113
  const msg = JSON.parse(new TextDecoder().decode(data));
57
114
  if (msg.type === "play") {
58
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
+ }
59
122
  setSyncEvents((prev) => [
60
123
  `${msg.user} pressed play at ${msg.time?.toFixed(1)}s`,
61
- ...prev.slice(0, 9),
124
+ ...prev.slice(0, 19),
62
125
  ]);
63
126
  } else if (msg.type === "pause") {
64
127
  setPlaying(false);
128
+ if (videoRef.current) {
129
+ ignoreSyncRef.current = true;
130
+ videoRef.current.pause();
131
+ setTimeout(() => { ignoreSyncRef.current = false; }, 200);
132
+ }
65
133
  setSyncEvents((prev) => [
66
134
  `${msg.user} paused at ${msg.time?.toFixed(1)}s`,
67
- ...prev.slice(0, 9),
135
+ ...prev.slice(0, 19),
68
136
  ]);
69
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
+ }
70
143
  setCurrentTime(msg.time || 0);
71
144
  setSyncEvents((prev) => [
72
145
  `${msg.user} seeked to ${msg.time?.toFixed(1)}s`,
73
- ...prev.slice(0, 9),
146
+ ...prev.slice(0, 19),
74
147
  ]);
75
148
  } else if (msg.type === "viewer-join") {
76
149
  setViewers((prev) =>
@@ -78,7 +151,15 @@ export default function WatchTogetherPage() {
78
151
  );
79
152
  setSyncEvents((prev) => [
80
153
  `${msg.name} joined the watch party`,
81
- ...prev.slice(0, 9),
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),
82
163
  ]);
83
164
  }
84
165
  } catch {
@@ -98,53 +179,147 @@ export default function WatchTogetherPage() {
98
179
  return () => {
99
180
  cancelled = true;
100
181
  connRef.current?.disconnect();
101
- if (intervalRef.current) clearInterval(intervalRef.current);
102
182
  };
103
183
  }, [session]);
104
184
 
185
+ // Sync video time updates
105
186
  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
- }
187
+ const video = videoRef.current;
188
+ if (!video) return;
189
+
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");
211
+ }
212
+ };
213
+
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);
219
+
114
220
  return () => {
115
- if (intervalRef.current) clearInterval(intervalRef.current);
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);
116
226
  };
117
- }, [playing]);
227
+ // eslint-disable-next-line react-hooks/exhaustive-deps
228
+ }, [activeSource]);
118
229
 
119
- function sendAction(type: string) {
230
+ const sendAction = useCallback((type: string) => {
120
231
  if (!connRef.current) return;
121
232
  const stream = connRef.current.stream("watch-room");
122
233
  stream.send(
123
234
  JSON.stringify({
124
235
  type,
125
236
  user: session?.user?.name,
126
- time: currentTime,
237
+ time: videoRef.current?.currentTime ?? currentTime,
127
238
  })
128
239
  );
129
- }
240
+ }, [session, currentTime]);
130
241
 
131
242
  function handlePlayPause() {
132
- const action = playing ? "pause" : "play";
133
- sendAction(action);
134
- setPlaying(!playing);
243
+ const video = videoRef.current;
244
+ if (!video) return;
245
+ if (video.paused) {
246
+ video.play().catch(() => { });
247
+ } else {
248
+ video.pause();
249
+ }
135
250
  }
136
251
 
137
252
  function handleSeek(seconds: number) {
138
- setCurrentTime((prev) => Math.max(0, prev + seconds));
139
- sendAction("seek");
253
+ const video = videoRef.current;
254
+ if (!video) return;
255
+ video.currentTime = Math.max(0, video.currentTime + seconds);
256
+ }
257
+
258
+ function handleProgressClick(e: React.MouseEvent<HTMLDivElement>) {
259
+ const video = videoRef.current;
260
+ if (!video || !duration) return;
261
+ const rect = e.currentTarget.getBoundingClientRect();
262
+ const ratio = (e.clientX - rect.left) / rect.width;
263
+ video.currentTime = ratio * duration;
264
+ }
265
+
266
+ function toggleFullscreen() {
267
+ const container = playerContainerRef.current;
268
+ if (!container) return;
269
+ if (!document.fullscreenElement) {
270
+ container.requestFullscreen().then(() => setIsFullscreen(true)).catch(() => { });
271
+ } else {
272
+ document.exitFullscreen().then(() => setIsFullscreen(false)).catch(() => { });
273
+ }
274
+ }
275
+
276
+ // Listen for fullscreen changes (e.g. pressing Esc)
277
+ useEffect(() => {
278
+ const handler = () => setIsFullscreen(!!document.fullscreenElement);
279
+ document.addEventListener("fullscreenchange", handler);
280
+ return () => document.removeEventListener("fullscreenchange", handler);
281
+ }, []);
282
+
283
+ function changeSource(source: VideoSource) {
284
+ setActiveSource(source);
285
+ setCurrentTime(0);
286
+ setPlaying(false);
287
+ setShowPicker(false);
288
+
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
+ );
298
+ }
299
+ }
300
+
301
+ function handleCustomUrl() {
302
+ if (!customUrl.trim()) return;
303
+ const url = customUrl.trim();
304
+ const type = getVideoType(url);
305
+ const label = type === "youtube"
306
+ ? `YouTube: ${extractYouTubeId(url)}`
307
+ : url.split("/").pop()?.split("?")[0] || "Custom Video";
308
+
309
+ changeSource({ label, url, type });
310
+ setCustomUrl("");
140
311
  }
141
312
 
142
313
  const formatTime = (t: number) => {
314
+ if (!t || isNaN(t)) return "0:00";
143
315
  const mins = Math.floor(t / 60);
144
316
  const secs = Math.floor(t % 60);
145
317
  return `${mins}:${secs.toString().padStart(2, "0")}`;
146
318
  };
147
319
 
320
+ const isYouTube = activeSource.type === "youtube";
321
+ const youtubeId = isYouTube ? extractYouTubeId(activeSource.url) : null;
322
+
148
323
  return (
149
324
  <div className="p-8">
150
325
  <div className="flex items-center justify-between mb-8">
@@ -189,38 +364,165 @@ export default function WatchTogetherPage() {
189
364
  </div>
190
365
  ) : (
191
366
  <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">
201
- <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)}%` }}
367
+ <div className="lg:col-span-2 space-y-4">
368
+ {/* Video Player */}
369
+ <div ref={playerContainerRef} className="glass rounded-2xl overflow-hidden">
370
+ <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"
204
379
  />
205
- </div>
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
+ )}
206
389
  </div>
207
390
 
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" />
391
+ {/* Controls */}
392
+ {!isYouTube && (
393
+ <>
394
+ {/* Progress bar */}
395
+ <div
396
+ className="h-1.5 bg-slate-800 cursor-pointer group relative"
397
+ onClick={handleProgressClick}
398
+ >
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>
405
+ </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" />}
422
+ </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
435
+ </div>
436
+ <div className="flex-1" />
437
+ <button onClick={toggleFullscreen}
438
+ className="p-2 rounded-lg hover:bg-slate-800 transition-colors" title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}>
439
+ {isFullscreen ? <Minimize className="w-4 h-4" /> : <Maximize className="w-4 h-4" />}
440
+ </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
+ </div>
446
+ )}
447
+ </div>
448
+
449
+ {/* Source Picker */}
450
+ <div className="glass rounded-2xl p-5">
451
+ <div className="flex items-center justify-between mb-4">
219
452
  <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>
453
+ <ListVideo className="w-4 h-4 text-pink-400" />
454
+ <h3 className="text-sm font-semibold text-pink-400">Now Playing</h3>
222
455
  </div>
456
+ <button
457
+ 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"
459
+ >
460
+ {showPicker ? "Close" : "Change Source"}
461
+ </button>
223
462
  </div>
463
+ <div className="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-slate-800/50 border border-slate-700">
464
+ <Video className="w-4 h-4 text-pink-400 shrink-0" />
465
+ <span className="text-sm text-slate-300 truncate">{activeSource.label}</span>
466
+ {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>
468
+ )}
469
+ </div>
470
+
471
+ {showPicker && (
472
+ <div className="mt-4 space-y-4">
473
+ {/* Custom URL Input */}
474
+ <div>
475
+ <label className="text-xs text-slate-400 mb-2 block">
476
+ Paste a YouTube URL or direct video link
477
+ </label>
478
+ <div className="flex gap-2">
479
+ <div className="flex-1 relative">
480
+ <Link2 className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
481
+ <input
482
+ type="text"
483
+ value={customUrl}
484
+ onChange={(e) => setCustomUrl(e.target.value)}
485
+ onKeyDown={(e) => e.key === "Enter" && handleCustomUrl()}
486
+ placeholder="https://youtube.com/watch?v=... or .mp4 link"
487
+ 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
+ />
489
+ </div>
490
+ <button
491
+ onClick={handleCustomUrl}
492
+ 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"
494
+ >
495
+ Load
496
+ </button>
497
+ </div>
498
+ </div>
499
+
500
+ {/* Preset Videos */}
501
+ <div>
502
+ <label className="text-xs text-slate-400 mb-2 block">
503
+ Or choose a sample video
504
+ </label>
505
+ <div className="space-y-2">
506
+ {PRESET_VIDEOS.map((video) => (
507
+ <button
508
+ key={video.url}
509
+ 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"
513
+ }`}
514
+ >
515
+ <Video className="w-4 h-4 text-slate-400 shrink-0" />
516
+ <span className="text-sm flex-1">{video.label}</span>
517
+ {activeSource.url === video.url && (
518
+ <Check className="w-4 h-4 text-pink-400 shrink-0" />
519
+ )}
520
+ </button>
521
+ ))}
522
+ </div>
523
+ </div>
524
+ </div>
525
+ )}
224
526
  </div>
225
527
  </div>
226
528
 
@@ -250,6 +552,17 @@ export default function WatchTogetherPage() {
250
552
  {syncEvents.map((event, i) => <div key={i} className="text-xs text-slate-400">{event}</div>)}
251
553
  </div>
252
554
  </div>
555
+
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>
565
+ </div>
253
566
  </div>
254
567
  </div>
255
568
  )}
@@ -7,6 +7,7 @@ import {
7
7
  Zap,
8
8
  LayoutDashboard,
9
9
  MessageSquare,
10
+ PhoneCall,
10
11
  Video,
11
12
  Database,
12
13
  Gamepad2,
@@ -14,15 +15,18 @@ import {
14
15
  LogOut,
15
16
  Loader2,
16
17
  ChevronRight,
18
+ Swords,
17
19
  } from "lucide-react";
18
20
  import { useEffect } from "react";
19
21
 
20
22
  const navItems = [
21
23
  { href: "/dashboard", label: "Overview", icon: LayoutDashboard },
22
24
  { href: "/dashboard/demos/chat", label: "Real-time Chat", icon: MessageSquare },
25
+ { href: "/dashboard/demos/video-call", label: "Video Call", icon: PhoneCall },
23
26
  { href: "/dashboard/demos/watch-together", label: "Watch Together", icon: Video },
24
27
  { href: "/dashboard/demos/queues", label: "Durable Queues", icon: Database },
25
28
  { href: "/dashboard/demos/game-sync", label: "Game Sync", icon: Gamepad2 },
29
+ { href: "/dashboard/demos/arena-game", label: "Arena Game", icon: Swords },
26
30
  { href: "/dashboard/demos/encrypted-chat", label: "E2E Encrypted", icon: Lock },
27
31
  ];
28
32