@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.
- package/README.md +1 -1
- package/dist/index.js +1 -1
- package/package.json +3 -3
- package/templates/aurora-auth-node-demo/README.md +43 -0
- package/templates/aurora-auth-node-demo/aurora.config.ts +15 -0
- package/templates/aurora-auth-node-demo/bun.lock +679 -0
- package/templates/aurora-auth-node-demo/drizzle.config.ts +9 -0
- package/templates/aurora-auth-node-demo/package.json +39 -0
- package/templates/aurora-auth-node-demo/postcss.config.mjs +7 -0
- package/templates/aurora-auth-node-demo/server.mjs +46 -0
- package/templates/aurora-auth-node-demo/src/actions/createMessage.action.server.ts +31 -0
- package/templates/aurora-auth-node-demo/src/aurora.auth.ts +65 -0
- package/templates/aurora-auth-node-demo/src/lib/auth-client.ts +30 -0
- package/templates/aurora-auth-node-demo/src/lib/auth.server.ts +11 -0
- package/templates/aurora-auth-node-demo/src/lib/auth.ts +30 -0
- package/templates/aurora-auth-node-demo/src/lib/db.ts +6 -0
- package/templates/aurora-auth-node-demo/src/lib/pulse.ts +45 -0
- package/templates/aurora-auth-node-demo/src/lib/schema.ts +107 -0
- package/templates/aurora-auth-node-demo/src/queries/listMessages.server.ts +25 -0
- package/templates/aurora-auth-node-demo/src/routes/api/auth/[...slug]/handler.ts +14 -0
- package/templates/aurora-auth-node-demo/src/routes/api/pulse/verify/handler.ts +55 -0
- package/templates/aurora-auth-node-demo/src/routes/auth/sign-in/page.client.tsx +132 -0
- package/templates/aurora-auth-node-demo/src/routes/auth/sign-in/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/auth/sign-up/page.client.tsx +154 -0
- package/templates/aurora-auth-node-demo/src/routes/auth/sign-up/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/arena-game/page.client.tsx +640 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/arena-game/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/chat/page.client.tsx +349 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/chat/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/encrypted-chat/page.client.tsx +472 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/encrypted-chat/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/game-sync/page.client.tsx +375 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/game-sync/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/queues/page.client.tsx +423 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/queues/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/video-call/page.client.tsx +840 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/video-call/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/watch-together/page.client.tsx +722 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/watch-together/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/layout.client.tsx +113 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/layout.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/page.client.tsx +195 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/favicon.ico +0 -0
- package/templates/aurora-auth-node-demo/src/routes/layout.tsx +18 -0
- package/templates/aurora-auth-node-demo/src/routes/page.client.tsx +263 -0
- package/templates/aurora-auth-node-demo/src/routes/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/styles/app.css +96 -0
- package/templates/aurora-auth-node-demo/tsconfig.json +27 -0
- package/templates/aurora-auth-node-demo/tsconfig.tsbuildinfo +1 -0
- package/templates/nextjs-auth-demo/README.md +1 -1
- package/templates/nextjs-auth-demo/next-env.d.ts +1 -1
- package/templates/nextjs-auth-demo/package.json +8 -7
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/arena-game/page.tsx +640 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/chat/page.tsx +124 -23
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/encrypted-chat/page.tsx +350 -76
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/game-sync/page.tsx +232 -49
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/queues/page.tsx +213 -87
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/video-call/page.tsx +840 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/watch-together/page.tsx +589 -123
- 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-node-demo/.env.example +10 -0
- package/templates/nextjs-auth-node-demo/Dockerfile +19 -0
- package/templates/nextjs-auth-node-demo/README.md +159 -0
- package/templates/nextjs-auth-node-demo/_gitignore +33 -0
- package/templates/nextjs-auth-node-demo/drizzle.config.ts +10 -0
- package/templates/nextjs-auth-node-demo/eslint.config.mjs +18 -0
- package/templates/nextjs-auth-node-demo/next-env.d.ts +6 -0
- package/templates/nextjs-auth-node-demo/next.config.ts +7 -0
- package/templates/nextjs-auth-node-demo/package.json +38 -0
- package/templates/nextjs-auth-node-demo/postcss.config.mjs +7 -0
- package/templates/nextjs-auth-node-demo/public/file.svg +1 -0
- package/templates/nextjs-auth-node-demo/public/globe.svg +1 -0
- package/templates/nextjs-auth-node-demo/public/next.svg +1 -0
- package/templates/nextjs-auth-node-demo/public/vercel.svg +1 -0
- package/templates/nextjs-auth-node-demo/public/window.svg +1 -0
- package/templates/nextjs-auth-node-demo/server.mjs +45 -0
- package/templates/nextjs-auth-node-demo/src/app/api/auth/[...all]/route.ts +4 -0
- package/templates/nextjs-auth-node-demo/src/app/api/pulse/verify/route.ts +54 -0
- package/templates/nextjs-auth-node-demo/src/app/auth/sign-in/page.tsx +131 -0
- package/templates/nextjs-auth-node-demo/src/app/auth/sign-up/page.tsx +153 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/arena-game/page.tsx +640 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/chat/page.tsx +349 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/encrypted-chat/page.tsx +472 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/game-sync/page.tsx +375 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/queues/page.tsx +423 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/video-call/page.tsx +840 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/watch-together/page.tsx +724 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/layout.tsx +113 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/page.tsx +195 -0
- package/templates/nextjs-auth-node-demo/src/app/favicon.ico +0 -0
- package/templates/nextjs-auth-node-demo/src/app/globals.css +96 -0
- package/templates/nextjs-auth-node-demo/src/app/layout.tsx +27 -0
- package/templates/nextjs-auth-node-demo/src/app/page.tsx +254 -0
- package/templates/nextjs-auth-node-demo/src/lib/auth-client.ts +15 -0
- package/templates/nextjs-auth-node-demo/src/lib/auth.ts +14 -0
- package/templates/nextjs-auth-node-demo/src/lib/db.ts +6 -0
- package/templates/nextjs-auth-node-demo/src/lib/pulse.ts +45 -0
- package/templates/nextjs-auth-node-demo/src/lib/schema.ts +107 -0
- 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
|
-
|
|
9
|
-
|
|
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
|
|
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 (
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
258
|
+
clearInterval(heartbeatInterval);
|
|
259
|
+
stream.close();
|
|
260
|
+
streamRef.current = null;
|
|
116
261
|
};
|
|
117
|
-
}, [
|
|
118
|
-
|
|
119
|
-
function
|
|
120
|
-
if (!
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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: `${
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
<
|
|
221
|
-
<
|
|
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-
|
|
230
|
-
<div className="space-y-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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-
|
|
248
|
-
<div className="space-y-2 max-h-
|
|
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
|
+
|