@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.
- package/README.md +1 -1
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/templates/nextjs-auth-demo/README.md +62 -11
- package/templates/nextjs-auth-demo/package.json +2 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/arena-game/page.tsx +623 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/chat/page.tsx +21 -5
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/encrypted-chat/page.tsx +220 -7
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/game-sync/page.tsx +199 -47
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/video-call/page.tsx +740 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/watch-together/page.tsx +364 -51
- package/templates/nextjs-auth-demo/src/app/dashboard/layout.tsx +4 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/page.tsx +53 -5
- package/templates/nextjs-auth-demo/src/app/layout.tsx +1 -1
- package/templates/nextjs-auth-demo/src/lib/auth.ts +2 -1
- package/templates/nextjs-auth-demo/src/lib/db.ts +2 -1
- package/templates/nextjs-auth-demo/src/lib/schema.ts +107 -0
|
@@ -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
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
227
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
228
|
+
}, [activeSource]);
|
|
118
229
|
|
|
119
|
-
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
<
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
{
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
<
|
|
221
|
-
<
|
|
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
|
|