@sansavision/create-pulse 0.4.4 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- 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/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 +20 -3
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/chat/page.tsx +108 -23
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/encrypted-chat/page.tsx +278 -217
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/game-sync/page.tsx +66 -35
- 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 +106 -6
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/watch-together/page.tsx +415 -262
- 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
|
@@ -4,23 +4,14 @@ 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,
|
|
16
|
-
Link2,
|
|
17
|
-
ExternalLink,
|
|
18
|
-
ListVideo,
|
|
19
|
-
Check,
|
|
20
|
-
Maximize,
|
|
21
|
-
Minimize,
|
|
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
|
|
22
10
|
} from "lucide-react";
|
|
23
11
|
import type { PulseConnection } from "@sansavision/pulse-sdk";
|
|
12
|
+
import dynamic from "next/dynamic";
|
|
13
|
+
|
|
14
|
+
const ReactPlayer = dynamic(() => import("react-player"), { ssr: false });
|
|
24
15
|
|
|
25
16
|
interface VideoSource {
|
|
26
17
|
label: string;
|
|
@@ -69,6 +60,9 @@ export default function WatchTogetherPage() {
|
|
|
69
60
|
const [connected, setConnected] = useState(false);
|
|
70
61
|
const [authUser, setAuthUser] = useState<{ id: string; claims: Record<string, string> } | null>(null);
|
|
71
62
|
const [connecting, setConnecting] = useState(true);
|
|
63
|
+
const [roomId, setRoomId] = useState("");
|
|
64
|
+
const [joinRoomId, setJoinRoomId] = useState("");
|
|
65
|
+
const [joinError, setJoinError] = useState("");
|
|
72
66
|
const [playing, setPlaying] = useState(false);
|
|
73
67
|
const [currentTime, setCurrentTime] = useState(0);
|
|
74
68
|
const [duration, setDuration] = useState(0);
|
|
@@ -78,13 +72,29 @@ export default function WatchTogetherPage() {
|
|
|
78
72
|
const [customUrl, setCustomUrl] = useState("");
|
|
79
73
|
const [showPicker, setShowPicker] = useState(false);
|
|
80
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
|
+
|
|
81
80
|
const connRef = useRef<PulseConnection | null>(null);
|
|
82
|
-
const
|
|
83
|
-
const
|
|
81
|
+
const streamRef = useRef<ReturnType<PulseConnection["stream"]> | null>(null);
|
|
82
|
+
const playerRef = useRef<any>(null);
|
|
84
83
|
const playerContainerRef = useRef<HTMLDivElement>(null);
|
|
85
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]);
|
|
86
97
|
|
|
87
|
-
// Connect to Pulse relay
|
|
88
98
|
useEffect(() => {
|
|
89
99
|
if (!session) return;
|
|
90
100
|
let cancelled = false;
|
|
@@ -102,71 +112,6 @@ export default function WatchTogetherPage() {
|
|
|
102
112
|
const user = (connection as any).user;
|
|
103
113
|
if (user) setAuthUser(user);
|
|
104
114
|
|
|
105
|
-
const stream = connection.stream("watch-room");
|
|
106
|
-
|
|
107
|
-
stream.send(
|
|
108
|
-
JSON.stringify({ type: "viewer-join", name: session!.user.name })
|
|
109
|
-
);
|
|
110
|
-
|
|
111
|
-
stream.on("data", (data: Uint8Array) => {
|
|
112
|
-
try {
|
|
113
|
-
const msg = JSON.parse(new TextDecoder().decode(data));
|
|
114
|
-
if (msg.type === "play") {
|
|
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
|
-
}
|
|
122
|
-
setSyncEvents((prev) => [
|
|
123
|
-
`${msg.user} pressed play at ${msg.time?.toFixed(1)}s`,
|
|
124
|
-
...prev.slice(0, 19),
|
|
125
|
-
]);
|
|
126
|
-
} else if (msg.type === "pause") {
|
|
127
|
-
setPlaying(false);
|
|
128
|
-
if (videoRef.current) {
|
|
129
|
-
ignoreSyncRef.current = true;
|
|
130
|
-
videoRef.current.pause();
|
|
131
|
-
setTimeout(() => { ignoreSyncRef.current = false; }, 200);
|
|
132
|
-
}
|
|
133
|
-
setSyncEvents((prev) => [
|
|
134
|
-
`${msg.user} paused at ${msg.time?.toFixed(1)}s`,
|
|
135
|
-
...prev.slice(0, 19),
|
|
136
|
-
]);
|
|
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
|
-
}
|
|
143
|
-
setCurrentTime(msg.time || 0);
|
|
144
|
-
setSyncEvents((prev) => [
|
|
145
|
-
`${msg.user} seeked to ${msg.time?.toFixed(1)}s`,
|
|
146
|
-
...prev.slice(0, 19),
|
|
147
|
-
]);
|
|
148
|
-
} else if (msg.type === "viewer-join") {
|
|
149
|
-
setViewers((prev) =>
|
|
150
|
-
prev.includes(msg.name) ? prev : [...prev, msg.name]
|
|
151
|
-
);
|
|
152
|
-
setSyncEvents((prev) => [
|
|
153
|
-
`${msg.name} joined the watch party`,
|
|
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),
|
|
163
|
-
]);
|
|
164
|
-
}
|
|
165
|
-
} catch {
|
|
166
|
-
/* ignore */
|
|
167
|
-
}
|
|
168
|
-
});
|
|
169
|
-
|
|
170
115
|
connection.on("disconnect", () => setConnected(false));
|
|
171
116
|
connection.on("reconnected", () => setConnected(true));
|
|
172
117
|
} catch (err) {
|
|
@@ -182,85 +127,212 @@ export default function WatchTogetherPage() {
|
|
|
182
127
|
};
|
|
183
128
|
}, [session]);
|
|
184
129
|
|
|
185
|
-
|
|
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
|
+
|
|
186
138
|
useEffect(() => {
|
|
187
|
-
|
|
188
|
-
|
|
139
|
+
if (!roomId || !connected || !connRef.current) return;
|
|
140
|
+
const sess = sessionRef.current;
|
|
141
|
+
if (!sess) return;
|
|
189
142
|
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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);
|
|
211
236
|
}
|
|
212
237
|
};
|
|
213
238
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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);
|
|
219
256
|
|
|
220
257
|
return () => {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
video.removeEventListener("pause", onPause);
|
|
225
|
-
video.removeEventListener("seeked", onSeeked);
|
|
258
|
+
clearInterval(heartbeatInterval);
|
|
259
|
+
stream.close();
|
|
260
|
+
streamRef.current = null;
|
|
226
261
|
};
|
|
227
|
-
|
|
228
|
-
}, [activeSource]);
|
|
229
|
-
|
|
230
|
-
const sendAction = useCallback((type: string) => {
|
|
231
|
-
if (!connRef.current) return;
|
|
232
|
-
const stream = connRef.current.stream("watch-room");
|
|
233
|
-
stream.send(
|
|
234
|
-
JSON.stringify({
|
|
235
|
-
type,
|
|
236
|
-
user: session?.user?.name,
|
|
237
|
-
time: videoRef.current?.currentTime ?? currentTime,
|
|
238
|
-
})
|
|
239
|
-
);
|
|
240
|
-
}, [session, currentTime]);
|
|
262
|
+
}, [roomId, connected]);
|
|
241
263
|
|
|
242
|
-
function
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
|
249
279
|
}
|
|
250
280
|
}
|
|
251
281
|
|
|
282
|
+
// Handlers mapped to ReactPlayer props
|
|
283
|
+
const onPlay = () => {
|
|
284
|
+
if (!ignoreSyncRef.current) {
|
|
285
|
+
setPlaying(true);
|
|
286
|
+
sendToStream("play");
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const onPause = () => {
|
|
291
|
+
if (!ignoreSyncRef.current) {
|
|
292
|
+
setPlaying(false);
|
|
293
|
+
sendToStream("pause");
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// ReactPlayer only calls onSeek when the user manually seeks.
|
|
298
|
+
const onSeeked = () => {
|
|
299
|
+
if (!ignoreSyncRef.current) {
|
|
300
|
+
sendToStream("seek");
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
function handlePlayPause() {
|
|
305
|
+
if (!playerRef.current) return;
|
|
306
|
+
setPlaying(!playing);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function handleStop() {
|
|
310
|
+
if (!playerRef.current) return;
|
|
311
|
+
setPlaying(false);
|
|
312
|
+
seekPlayerTo(0);
|
|
313
|
+
setCurrentTime(0);
|
|
314
|
+
sendToStream("seek");
|
|
315
|
+
sendToStream("pause");
|
|
316
|
+
}
|
|
317
|
+
|
|
252
318
|
function handleSeek(seconds: number) {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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));
|
|
256
329
|
}
|
|
257
330
|
|
|
258
331
|
function handleProgressClick(e: React.MouseEvent<HTMLDivElement>) {
|
|
259
|
-
|
|
260
|
-
if (!video || !duration) return;
|
|
332
|
+
if (!playerRef.current || !duration) return;
|
|
261
333
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
262
334
|
const ratio = (e.clientX - rect.left) / rect.width;
|
|
263
|
-
|
|
335
|
+
seekPlayerTo(ratio * duration);
|
|
264
336
|
}
|
|
265
337
|
|
|
266
338
|
function toggleFullscreen() {
|
|
@@ -273,7 +345,6 @@ export default function WatchTogetherPage() {
|
|
|
273
345
|
}
|
|
274
346
|
}
|
|
275
347
|
|
|
276
|
-
// Listen for fullscreen changes (e.g. pressing Esc)
|
|
277
348
|
useEffect(() => {
|
|
278
349
|
const handler = () => setIsFullscreen(!!document.fullscreenElement);
|
|
279
350
|
document.addEventListener("fullscreenchange", handler);
|
|
@@ -286,15 +357,20 @@ export default function WatchTogetherPage() {
|
|
|
286
357
|
setPlaying(false);
|
|
287
358
|
setShowPicker(false);
|
|
288
359
|
|
|
289
|
-
if (
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
+
}
|
|
298
374
|
}
|
|
299
375
|
}
|
|
300
376
|
|
|
@@ -310,6 +386,22 @@ export default function WatchTogetherPage() {
|
|
|
310
386
|
setCustomUrl("");
|
|
311
387
|
}
|
|
312
388
|
|
|
389
|
+
function generateRoom() {
|
|
390
|
+
const code = Math.random().toString(36).slice(2, 8).toUpperCase();
|
|
391
|
+
setRoomId(code);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function joinRoom() {
|
|
395
|
+
const code = joinRoomId.trim().toUpperCase();
|
|
396
|
+
if (!code) return;
|
|
397
|
+
if (!/^[A-Z0-9]{6,12}$/.test(code)) {
|
|
398
|
+
setJoinError("Room code must be 6-12 alphanumeric characters.");
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
setJoinError("");
|
|
402
|
+
setRoomId(code);
|
|
403
|
+
}
|
|
404
|
+
|
|
313
405
|
const formatTime = (t: number) => {
|
|
314
406
|
if (!t || isNaN(t)) return "0:00";
|
|
315
407
|
const mins = Math.floor(t / 60);
|
|
@@ -320,6 +412,37 @@ export default function WatchTogetherPage() {
|
|
|
320
412
|
const isYouTube = activeSource.type === "youtube";
|
|
321
413
|
const youtubeId = isYouTube ? extractYouTubeId(activeSource.url) : null;
|
|
322
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
|
+
|
|
323
446
|
return (
|
|
324
447
|
<div className="p-8">
|
|
325
448
|
<div className="flex items-center justify-between mb-8">
|
|
@@ -329,8 +452,8 @@ export default function WatchTogetherPage() {
|
|
|
329
452
|
</div>
|
|
330
453
|
<div>
|
|
331
454
|
<h1 className="text-2xl font-bold">Watch Together</h1>
|
|
332
|
-
<p className="text-sm text-slate-500">
|
|
333
|
-
|
|
455
|
+
<p className="text-sm text-slate-500 font-mono">
|
|
456
|
+
Room: {roomId}
|
|
334
457
|
</p>
|
|
335
458
|
</div>
|
|
336
459
|
</div>
|
|
@@ -366,84 +489,88 @@ export default function WatchTogetherPage() {
|
|
|
366
489
|
<div className="grid lg:grid-cols-3 gap-8">
|
|
367
490
|
<div className="lg:col-span-2 space-y-4">
|
|
368
491
|
{/* Video Player */}
|
|
369
|
-
<div ref={playerContainerRef} className="glass rounded-2xl overflow-hidden">
|
|
492
|
+
<div ref={playerContainerRef} className="glass rounded-2xl overflow-hidden shadow-2xl">
|
|
370
493
|
<div className="aspect-video bg-black relative">
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
/>
|
|
388
|
-
)}
|
|
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
|
+
/>
|
|
389
510
|
</div>
|
|
390
511
|
|
|
391
512
|
{/* Controls */}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
+
>
|
|
395
519
|
<div
|
|
396
|
-
className="h-
|
|
397
|
-
|
|
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}%` }}
|
|
398
522
|
>
|
|
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>
|
|
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" />
|
|
405
524
|
</div>
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
<
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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" />}
|
|
422
549
|
</button>
|
|
423
|
-
<
|
|
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
|
|
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" />
|
|
435
551
|
</div>
|
|
436
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>
|
|
437
568
|
<button onClick={toggleFullscreen}
|
|
438
|
-
className="p-
|
|
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"}>
|
|
439
570
|
{isFullscreen ? <Minimize className="w-4 h-4" /> : <Maximize className="w-4 h-4" />}
|
|
440
571
|
</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
572
|
</div>
|
|
446
|
-
|
|
573
|
+
</div>
|
|
447
574
|
</div>
|
|
448
575
|
|
|
449
576
|
{/* Source Picker */}
|
|
@@ -455,25 +582,25 @@ export default function WatchTogetherPage() {
|
|
|
455
582
|
</div>
|
|
456
583
|
<button
|
|
457
584
|
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"
|
|
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"
|
|
459
586
|
>
|
|
460
587
|
{showPicker ? "Close" : "Change Source"}
|
|
461
588
|
</button>
|
|
462
589
|
</div>
|
|
463
|
-
<div className="flex items-center gap-3 px-
|
|
590
|
+
<div className="flex items-center gap-3 px-4 py-3 rounded-xl bg-[#0f172a] border border-slate-800 shadow-inner">
|
|
464
591
|
<Video className="w-4 h-4 text-pink-400 shrink-0" />
|
|
465
|
-
<span className="text-sm text-slate-
|
|
592
|
+
<span className="text-sm font-medium text-slate-200 truncate pr-4">{activeSource.label}</span>
|
|
466
593
|
{activeSource.type === "youtube" && (
|
|
467
|
-
<span className="text-[10px] px-
|
|
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>
|
|
468
595
|
)}
|
|
469
596
|
</div>
|
|
470
597
|
|
|
471
598
|
{showPicker && (
|
|
472
|
-
<div className="mt-
|
|
599
|
+
<div className="mt-5 space-y-5 animate-in fade-in slide-in-from-top-2">
|
|
473
600
|
{/* Custom URL Input */}
|
|
474
601
|
<div>
|
|
475
|
-
<label className="text-xs text-slate-400 mb-2 block">
|
|
476
|
-
|
|
602
|
+
<label className="text-xs font-semibold text-slate-400 uppercase tracking-widest mb-2 block">
|
|
603
|
+
Custom URL
|
|
477
604
|
</label>
|
|
478
605
|
<div className="flex gap-2">
|
|
479
606
|
<div className="flex-1 relative">
|
|
@@ -483,14 +610,14 @@ export default function WatchTogetherPage() {
|
|
|
483
610
|
value={customUrl}
|
|
484
611
|
onChange={(e) => setCustomUrl(e.target.value)}
|
|
485
612
|
onKeyDown={(e) => e.key === "Enter" && handleCustomUrl()}
|
|
486
|
-
placeholder="
|
|
613
|
+
placeholder="YouTube / Video URL"
|
|
487
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"
|
|
488
615
|
/>
|
|
489
616
|
</div>
|
|
490
617
|
<button
|
|
491
618
|
onClick={handleCustomUrl}
|
|
492
619
|
disabled={!customUrl.trim()}
|
|
493
|
-
className="px-
|
|
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"
|
|
494
621
|
>
|
|
495
622
|
Load
|
|
496
623
|
</button>
|
|
@@ -499,21 +626,21 @@ export default function WatchTogetherPage() {
|
|
|
499
626
|
|
|
500
627
|
{/* Preset Videos */}
|
|
501
628
|
<div>
|
|
502
|
-
<label className="text-xs text-slate-400 mb-2 block">
|
|
503
|
-
|
|
629
|
+
<label className="text-xs font-semibold text-slate-400 uppercase tracking-widest mb-2 block">
|
|
630
|
+
Presets
|
|
504
631
|
</label>
|
|
505
632
|
<div className="space-y-2">
|
|
506
633
|
{PRESET_VIDEOS.map((video) => (
|
|
507
634
|
<button
|
|
508
635
|
key={video.url}
|
|
509
636
|
onClick={() => changeSource(video)}
|
|
510
|
-
className={`w-full flex items-center gap-3 px-
|
|
511
|
-
? "border-pink-500/50 bg-pink-500/10"
|
|
512
|
-
: "border-slate-
|
|
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"
|
|
513
640
|
}`}
|
|
514
641
|
>
|
|
515
642
|
<Video className="w-4 h-4 text-slate-400 shrink-0" />
|
|
516
|
-
<span className="text-sm flex-1">{video.label}</span>
|
|
643
|
+
<span className="text-sm font-medium flex-1 text-slate-300">{video.label}</span>
|
|
517
644
|
{activeSource.url === video.url && (
|
|
518
645
|
<Check className="w-4 h-4 text-pink-400 shrink-0" />
|
|
519
646
|
)}
|
|
@@ -528,40 +655,65 @@ export default function WatchTogetherPage() {
|
|
|
528
655
|
|
|
529
656
|
<div className="space-y-6">
|
|
530
657
|
<div className="glass rounded-2xl p-6">
|
|
531
|
-
<h3 className="text-sm font-semibold mb-
|
|
532
|
-
<div className="space-y-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
+
})}
|
|
545
687
|
</div>
|
|
546
688
|
</div>
|
|
547
689
|
|
|
548
690
|
<div className="glass rounded-2xl p-6">
|
|
549
|
-
<h3 className="text-sm font-semibold mb-
|
|
550
|
-
<div className="space-y-2 max-h-
|
|
551
|
-
{syncEvents.length === 0 && <p className="text-xs text-slate-500">No sync events yet. Press play!</p>}
|
|
552
|
-
{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>)}
|
|
553
695
|
</div>
|
|
554
696
|
</div>
|
|
555
697
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
698
|
+
<div className="glass rounded-2xl p-4 bg-black/40">
|
|
699
|
+
<div className="flex justify-between mb-2">
|
|
700
|
+
<div>
|
|
701
|
+
<p className="text-[10px] text-slate-500 uppercase tracking-widest">Message RTT</p>
|
|
702
|
+
<p className="text-sm font-mono text-emerald-400">{metrics.rttMs > 0 ? `${metrics.rttMs}ms` : "—"}</p>
|
|
703
|
+
</div>
|
|
704
|
+
<div className="text-center">
|
|
705
|
+
<p className="text-[10px] text-slate-500 uppercase tracking-widest">Sync Msgs</p>
|
|
706
|
+
<p className="text-sm font-mono text-cyan-400">{metrics.msgCount}</p>
|
|
707
|
+
</div>
|
|
708
|
+
<div className="text-right">
|
|
709
|
+
<p className="text-[10px] text-slate-500 uppercase tracking-widest">Viewers</p>
|
|
710
|
+
<p className="text-sm font-mono text-pink-400">{viewers.length}</p>
|
|
711
|
+
</div>
|
|
712
|
+
</div>
|
|
713
|
+
<div className="text-center">
|
|
714
|
+
<p className="text-[10px] text-slate-500 uppercase tracking-widest">Quality</p>
|
|
715
|
+
<p className="text-sm font-mono text-amber-400">{quality}</p>
|
|
716
|
+
</div>
|
|
565
717
|
</div>
|
|
566
718
|
</div>
|
|
567
719
|
</div>
|
|
@@ -569,3 +721,4 @@ export default function WatchTogetherPage() {
|
|
|
569
721
|
</div>
|
|
570
722
|
);
|
|
571
723
|
}
|
|
724
|
+
|