@sansavision/create-pulse 0.1.0-alpha.8 → 0.1.0-alpha.9
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 +1 -1
- package/templates/react-all-features/src/components/EncryptedChat.tsx +31 -0
- package/templates/react-all-features/src/components/GameSync.tsx +38 -10
- package/templates/react-all-features/src/components/ServerMetrics.tsx +31 -6
- package/templates/react-watch-together/src/App.tsx +64 -4
- package/templates/react-watch-together/src/components/VideoPlayer.tsx +8 -1
package/package.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
|
2
|
+
import { Clock } from 'lucide-react';
|
|
2
3
|
|
|
3
4
|
// Web Crypto API: ECDH P-256 key agreement + AES-256-GCM symmetric encryption
|
|
4
5
|
// This is production-grade E2E encryption — the relay never sees plaintext.
|
|
@@ -89,6 +90,10 @@ export function EncryptedChat({ useChannel, logEvent }: EncryptedChatProps) {
|
|
|
89
90
|
const [aliceDraft, setAliceDraft] = useState('');
|
|
90
91
|
const [bobDraft, setBobDraft] = useState('');
|
|
91
92
|
|
|
93
|
+
// Delay metrics
|
|
94
|
+
const [lastDelay, setLastDelay] = useState<number | null>(null);
|
|
95
|
+
const delayHistoryRef = useRef<number[]>([]);
|
|
96
|
+
|
|
92
97
|
// Generate ECDH keypairs and derive shared secrets on mount
|
|
93
98
|
useEffect(() => {
|
|
94
99
|
const setupCrypto = async () => {
|
|
@@ -151,6 +156,8 @@ export function EncryptedChat({ useChannel, logEvent }: EncryptedChatProps) {
|
|
|
151
156
|
const decryptKey = sender === 'Alice' ? keysRef.current.bobShared : keysRef.current.aliceShared;
|
|
152
157
|
if (!encryptKey || !decryptKey) return;
|
|
153
158
|
|
|
159
|
+
const sendTs = Date.now();
|
|
160
|
+
|
|
154
161
|
// 1) Encrypt the plaintext
|
|
155
162
|
const encrypted = await WebCrypto.encrypt(encryptKey, text);
|
|
156
163
|
const b64 = btoa(String.fromCharCode(...encrypted));
|
|
@@ -180,11 +187,19 @@ export function EncryptedChat({ useChannel, logEvent }: EncryptedChatProps) {
|
|
|
180
187
|
} else {
|
|
181
188
|
setAliceMessages((prev: ChatMessage[]) => [...prev, { me: false, text: decrypted, encrypted: ciphertextPreview }]);
|
|
182
189
|
}
|
|
190
|
+
// Record E2E loopback delay
|
|
191
|
+
const delay = Date.now() - sendTs;
|
|
192
|
+
setLastDelay(delay);
|
|
193
|
+
delayHistoryRef.current = [...delayHistoryRef.current.slice(-29), delay];
|
|
183
194
|
} catch (err) {
|
|
184
195
|
console.error('Local decrypt failed:', err);
|
|
185
196
|
}
|
|
186
197
|
};
|
|
187
198
|
|
|
199
|
+
const avgDelay = delayHistoryRef.current.length > 0
|
|
200
|
+
? Math.round(delayHistoryRef.current.reduce((a: number, b: number) => a + b, 0) / delayHistoryRef.current.length)
|
|
201
|
+
: null;
|
|
202
|
+
|
|
188
203
|
return (
|
|
189
204
|
<div className="space-y-6 max-w-5xl mx-auto">
|
|
190
205
|
{/* Crypto status banner */}
|
|
@@ -199,6 +214,22 @@ export function EncryptedChat({ useChannel, logEvent }: EncryptedChatProps) {
|
|
|
199
214
|
</div>
|
|
200
215
|
</div>
|
|
201
216
|
|
|
217
|
+
{/* Live Metrics */}
|
|
218
|
+
<div className="grid grid-cols-2 gap-4">
|
|
219
|
+
<div className="glass-panel px-4 py-3 flex items-center justify-between">
|
|
220
|
+
<div className="flex items-center gap-2 text-xs text-textMuted"><Clock className="w-3.5 h-3.5" />Msg Delay</div>
|
|
221
|
+
<span className={`text-sm font-bold font-mono ${lastDelay !== null && lastDelay > 50 ? 'text-brandAmber' : 'text-brandEmerald'}`}>
|
|
222
|
+
{lastDelay !== null ? `${lastDelay}ms` : '—'}
|
|
223
|
+
</span>
|
|
224
|
+
</div>
|
|
225
|
+
<div className="glass-panel px-4 py-3 flex items-center justify-between">
|
|
226
|
+
<div className="flex items-center gap-2 text-xs text-textMuted"><Clock className="w-3.5 h-3.5" />Relay RTT</div>
|
|
227
|
+
<span className="text-sm font-bold font-mono text-textMain">
|
|
228
|
+
{avgDelay !== null ? `~${avgDelay * 2}ms` : '—'}
|
|
229
|
+
</span>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
202
233
|
<div className="grid grid-cols-2 gap-8">
|
|
203
234
|
{/* Alice Panel */}
|
|
204
235
|
<div className="glass-panel overflow-hidden border-brandPurple/30">
|
|
@@ -11,6 +11,10 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
|
|
|
11
11
|
|
|
12
12
|
const [metrics, setMetrics] = useState({ ups: 0, bytes: 0 });
|
|
13
13
|
|
|
14
|
+
// Delay tracking
|
|
15
|
+
const [lastDelay, setLastDelay] = useState<number | null>(null);
|
|
16
|
+
const delayHistoryRef = useRef<number[]>([]);
|
|
17
|
+
|
|
14
18
|
const localPlayerRef = useRef({ x: 0.3, y: 0.5, color: '#7C3AED', label: 'P1' });
|
|
15
19
|
const remotePlayerRef = useRef({ x: 0.7, y: 0.5, color: '#10B981', label: 'P2' });
|
|
16
20
|
const remoteViewRef = useRef({
|
|
@@ -58,6 +62,13 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
|
|
|
58
62
|
remoteViewRef.current.p1.y += (state.p1.y - remoteViewRef.current.p1.y) * 0.7;
|
|
59
63
|
remoteViewRef.current.p2.x += (state.p2.x - remoteViewRef.current.p2.x) * 0.7;
|
|
60
64
|
remoteViewRef.current.p2.y += (state.p2.y - remoteViewRef.current.p2.y) * 0.7;
|
|
65
|
+
|
|
66
|
+
// Track delay from received messages
|
|
67
|
+
if (msg.ts) {
|
|
68
|
+
const delay = Date.now() - msg.ts;
|
|
69
|
+
setLastDelay(delay);
|
|
70
|
+
delayHistoryRef.current = [...delayHistoryRef.current.slice(-29), delay];
|
|
71
|
+
}
|
|
61
72
|
}
|
|
62
73
|
}, []);
|
|
63
74
|
|
|
@@ -107,7 +118,7 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
|
|
|
107
118
|
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
|
|
108
119
|
}
|
|
109
120
|
|
|
110
|
-
collectiblesRef.current.forEach((c) => {
|
|
121
|
+
collectiblesRef.current.forEach((c: any) => {
|
|
111
122
|
if (c.collected) return;
|
|
112
123
|
ctx.beginPath();
|
|
113
124
|
ctx.arc(c.x * w, c.y * h, 8, 0, Math.PI * 2);
|
|
@@ -131,6 +142,11 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
|
|
|
131
142
|
}
|
|
132
143
|
};
|
|
133
144
|
|
|
145
|
+
// Computed avg delay
|
|
146
|
+
const avgDelay = delayHistoryRef.current.length > 0
|
|
147
|
+
? Math.round(delayHistoryRef.current.reduce((a: number, b: number) => a + b, 0) / delayHistoryRef.current.length)
|
|
148
|
+
: null;
|
|
149
|
+
|
|
134
150
|
// Game loop
|
|
135
151
|
useEffect(() => {
|
|
136
152
|
let animationId: number;
|
|
@@ -155,7 +171,7 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
|
|
|
155
171
|
remoteP.x = 0.5 + Math.cos(aiRef.current.angle) * 0.3;
|
|
156
172
|
remoteP.y = 0.5 + Math.sin(aiRef.current.angle * 1.3) * 0.25;
|
|
157
173
|
|
|
158
|
-
collectiblesRef.current.forEach((c) => {
|
|
174
|
+
collectiblesRef.current.forEach((c: any) => {
|
|
159
175
|
if (c.collected) return;
|
|
160
176
|
const d1 = Math.hypot(localP.x - c.x, localP.y - c.y);
|
|
161
177
|
const d2 = Math.hypot(remoteP.x - c.x, remoteP.y - c.y);
|
|
@@ -163,8 +179,8 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
|
|
|
163
179
|
if (d2 < 0.04) { c.collected = true; scoreRef.current.p2++; }
|
|
164
180
|
});
|
|
165
181
|
|
|
166
|
-
if (collectiblesRef.current.every((c) => c.collected)) {
|
|
167
|
-
collectiblesRef.current.forEach((c) => {
|
|
182
|
+
if (collectiblesRef.current.every((c: any) => c.collected)) {
|
|
183
|
+
collectiblesRef.current.forEach((c: any) => {
|
|
168
184
|
c.x = 0.1 + Math.random() * 0.8;
|
|
169
185
|
c.y = 0.1 + Math.random() * 0.8;
|
|
170
186
|
c.collected = false;
|
|
@@ -176,11 +192,13 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
|
|
|
176
192
|
// Sync state at ~60fps
|
|
177
193
|
statsRef.current.frameCount++;
|
|
178
194
|
statsRef.current.updatesThisSecond++;
|
|
195
|
+
const sendTs = Date.now();
|
|
179
196
|
const payload = {
|
|
180
197
|
p1: { x: localP.x, y: localP.y },
|
|
181
198
|
p2: { x: remoteP.x, y: remoteP.y },
|
|
182
199
|
score: scoreRef.current,
|
|
183
200
|
seq: statsRef.current.frameCount,
|
|
201
|
+
ts: sendTs,
|
|
184
202
|
};
|
|
185
203
|
|
|
186
204
|
const payloadStr = JSON.stringify(payload);
|
|
@@ -189,6 +207,11 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
|
|
|
189
207
|
// Send to peers
|
|
190
208
|
broadcast(payload);
|
|
191
209
|
|
|
210
|
+
// Record loopback delay so metrics are visible
|
|
211
|
+
const loopbackDelay = Date.now() - sendTs;
|
|
212
|
+
setLastDelay(loopbackDelay);
|
|
213
|
+
delayHistoryRef.current = [...delayHistoryRef.current.slice(-29), loopbackDelay];
|
|
214
|
+
|
|
192
215
|
// Interpolate fallback for when broadcast doesn't echo locally fast enough
|
|
193
216
|
const rv = remoteViewRef.current;
|
|
194
217
|
rv.p1.x += (localP.x - rv.p1.x) * 0.1;
|
|
@@ -249,18 +272,23 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
|
|
|
249
272
|
<div className="text-[10px] text-textDim uppercase tracking-widest mt-1">Updates/sec</div>
|
|
250
273
|
</div>
|
|
251
274
|
<div className="glass-panel p-4 text-center">
|
|
252
|
-
<div className="text-2xl font-bold font-mono text-brandPurple">
|
|
253
|
-
<div className="text-[10px] text-textDim uppercase tracking-widest mt-1">
|
|
275
|
+
<div className="text-2xl font-bold font-mono text-brandPurple">{metrics.bytes}</div>
|
|
276
|
+
<div className="text-[10px] text-textDim uppercase tracking-widest mt-1">Bytes/sec</div>
|
|
254
277
|
</div>
|
|
255
278
|
<div className="glass-panel p-4 text-center">
|
|
256
|
-
<div className=
|
|
257
|
-
|
|
279
|
+
<div className={`text-2xl font-bold font-mono ${lastDelay !== null && lastDelay > 50 ? 'text-brandAmber' : 'text-brandEmerald'}`}>
|
|
280
|
+
{lastDelay !== null ? `${lastDelay}ms` : '—'}
|
|
281
|
+
</div>
|
|
282
|
+
<div className="text-[10px] text-textDim uppercase tracking-widest mt-1">Msg Delay</div>
|
|
258
283
|
</div>
|
|
259
284
|
<div className="glass-panel p-4 text-center">
|
|
260
|
-
<div className="text-2xl font-bold font-mono text-
|
|
261
|
-
|
|
285
|
+
<div className="text-2xl font-bold font-mono text-brandEmerald">
|
|
286
|
+
{avgDelay !== null ? `~${avgDelay * 2}ms` : '—'}
|
|
287
|
+
</div>
|
|
288
|
+
<div className="text-[10px] text-textDim uppercase tracking-widest mt-1">Relay RTT</div>
|
|
262
289
|
</div>
|
|
263
290
|
</div>
|
|
264
291
|
</div>
|
|
265
292
|
);
|
|
266
293
|
}
|
|
294
|
+
|
|
@@ -10,6 +10,10 @@ export function ServerMetrics({ useChannel, logEvent }: ServerMetricsProps) {
|
|
|
10
10
|
const cpuCanvas = useRef<HTMLCanvasElement>(null);
|
|
11
11
|
const memCanvas = useRef<HTMLCanvasElement>(null);
|
|
12
12
|
|
|
13
|
+
// Delay tracking
|
|
14
|
+
const [lastDelay, setLastDelay] = useState<number | null>(null);
|
|
15
|
+
const delayHistoryRef = useRef<number[]>([]);
|
|
16
|
+
|
|
13
17
|
const historyRef = useRef({
|
|
14
18
|
cpu: Array(60).fill(0),
|
|
15
19
|
mem: Array(60).fill(0),
|
|
@@ -66,6 +70,13 @@ export function ServerMetrics({ useChannel, logEvent }: ServerMetricsProps) {
|
|
|
66
70
|
if (memCanvas.current) drawChart(memCanvas.current, memHist, '#7C3AED'); // Purple
|
|
67
71
|
|
|
68
72
|
logEvent(`RECV [Metrics]: CPU=${metrics.cpu.toFixed(1)}% MEM=${metrics.mem.toFixed(1)}%`);
|
|
73
|
+
|
|
74
|
+
// Track delay from timestamps
|
|
75
|
+
if (metrics.ts) {
|
|
76
|
+
const delay = Date.now() - metrics.ts;
|
|
77
|
+
setLastDelay(delay);
|
|
78
|
+
delayHistoryRef.current = [...delayHistoryRef.current.slice(-29), delay];
|
|
79
|
+
}
|
|
69
80
|
}
|
|
70
81
|
}, [logEvent]);
|
|
71
82
|
|
|
@@ -78,21 +89,31 @@ export function ServerMetrics({ useChannel, logEvent }: ServerMetricsProps) {
|
|
|
78
89
|
// Check if we haven't received real data in the last tick
|
|
79
90
|
// For the sake of the demo, if we want this React app to just work standalone:
|
|
80
91
|
// We simulate that the server is broadcasting.
|
|
92
|
+
const sendTs = Date.now();
|
|
81
93
|
const fakeMetrics = {
|
|
82
94
|
type: "server_metrics",
|
|
83
95
|
cpu: 10 + Math.random() * 20 + Math.sin(Date.now() / 1000) * 10,
|
|
84
96
|
mem: 40 + Math.random() * 5 + Math.cos(Date.now() / 2000) * 10,
|
|
85
97
|
net: Math.random() * 100,
|
|
86
|
-
disk: Math.random() * 5
|
|
98
|
+
disk: Math.random() * 5,
|
|
99
|
+
ts: sendTs,
|
|
87
100
|
};
|
|
88
101
|
// Normally the server does this, here we do it so the demo "just works" locally
|
|
89
102
|
broadcast(fakeMetrics);
|
|
90
|
-
|
|
103
|
+
|
|
104
|
+
// Record loopback delay
|
|
105
|
+
const loopbackDelay = Date.now() - sendTs;
|
|
106
|
+
setLastDelay(loopbackDelay);
|
|
107
|
+
delayHistoryRef.current = [...delayHistoryRef.current.slice(-29), loopbackDelay];
|
|
91
108
|
}, 1000);
|
|
92
109
|
|
|
93
110
|
return () => clearInterval(interval);
|
|
94
111
|
}, [broadcast]);
|
|
95
112
|
|
|
113
|
+
const avgDelay = delayHistoryRef.current.length > 0
|
|
114
|
+
? Math.round(delayHistoryRef.current.reduce((a: number, b: number) => a + b, 0) / delayHistoryRef.current.length)
|
|
115
|
+
: null;
|
|
116
|
+
|
|
96
117
|
return (
|
|
97
118
|
<div className="space-y-6 max-w-5xl mx-auto">
|
|
98
119
|
<div className="grid grid-cols-4 gap-4">
|
|
@@ -105,12 +126,16 @@ export function ServerMetrics({ useChannel, logEvent }: ServerMetricsProps) {
|
|
|
105
126
|
<div className="text-3xl font-bold text-brandPurple font-mono">{stats.mem.toFixed(1)}%</div>
|
|
106
127
|
</div>
|
|
107
128
|
<div className="glass-panel p-6 text-center">
|
|
108
|
-
<div className="text-[10px] text-textDim uppercase tracking-widest mb-2 font-semibold">
|
|
109
|
-
<div className=
|
|
129
|
+
<div className="text-[10px] text-textDim uppercase tracking-widest mb-2 font-semibold">Msg Delay</div>
|
|
130
|
+
<div className={`text-3xl font-bold font-mono ${lastDelay !== null && lastDelay > 50 ? 'text-brandAmber' : 'text-brandEmerald'}`}>
|
|
131
|
+
{lastDelay !== null ? `${lastDelay}ms` : '—'}
|
|
132
|
+
</div>
|
|
110
133
|
</div>
|
|
111
134
|
<div className="glass-panel p-6 text-center">
|
|
112
|
-
<div className="text-[10px] text-textDim uppercase tracking-widest mb-2 font-semibold">
|
|
113
|
-
<div className="text-3xl font-bold text-
|
|
135
|
+
<div className="text-[10px] text-textDim uppercase tracking-widest mb-2 font-semibold">Relay RTT</div>
|
|
136
|
+
<div className="text-3xl font-bold text-brandEmerald font-mono">
|
|
137
|
+
{avgDelay !== null ? `~${avgDelay * 2}ms` : '—'}
|
|
138
|
+
</div>
|
|
114
139
|
</div>
|
|
115
140
|
</div>
|
|
116
141
|
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { useState, useCallback, useRef } from 'react';
|
|
2
2
|
import { usePulse } from './hooks/usePulse';
|
|
3
3
|
import { VideoPlayer } from './components/VideoPlayer';
|
|
4
|
-
import { Activity, Users, Video } from 'lucide-react';
|
|
4
|
+
import { Activity, Users, Video, Settings } from 'lucide-react';
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// Quality options for the video
|
|
7
|
+
const QUALITY_OPTIONS = [
|
|
8
|
+
{ label: '1080p', value: '1080p', src: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4' },
|
|
9
|
+
{ label: '720p', value: '720p', src: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4' },
|
|
10
|
+
{ label: '480p', value: '480p', src: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4' },
|
|
11
|
+
];
|
|
7
12
|
const STREAM_NAME = "watch-together-demo";
|
|
8
13
|
|
|
9
14
|
export default function App() {
|
|
@@ -17,6 +22,10 @@ export default function App() {
|
|
|
17
22
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
18
23
|
const [events, setEvents] = useState<{ id: number, action: string, time: string }[]>([]);
|
|
19
24
|
|
|
25
|
+
// Video quality
|
|
26
|
+
const [selectedQuality, setSelectedQuality] = useState(QUALITY_OPTIONS[0]);
|
|
27
|
+
const [showQualityMenu, setShowQualityMenu] = useState(false);
|
|
28
|
+
|
|
20
29
|
// Track message round-trip delay
|
|
21
30
|
const [lastDelay, setLastDelay] = useState<number | null>(null);
|
|
22
31
|
const delayHistoryRef = useRef<number[]>([]);
|
|
@@ -54,6 +63,12 @@ export default function App() {
|
|
|
54
63
|
case 'tick':
|
|
55
64
|
if (msg.time !== undefined) setPeerTime(msg.time);
|
|
56
65
|
break;
|
|
66
|
+
case 'quality':
|
|
67
|
+
if (msg.quality) {
|
|
68
|
+
const q = QUALITY_OPTIONS.find((o: any) => o.value === msg.quality);
|
|
69
|
+
if (q) setSelectedQuality(q);
|
|
70
|
+
}
|
|
71
|
+
break;
|
|
57
72
|
}
|
|
58
73
|
}, [logEvent]);
|
|
59
74
|
|
|
@@ -87,6 +102,13 @@ export default function App() {
|
|
|
87
102
|
delayHistoryRef.current = [...delayHistoryRef.current.slice(-29), loopbackDelay];
|
|
88
103
|
}, [broadcast, logEvent]);
|
|
89
104
|
|
|
105
|
+
const handleQualityChange = useCallback((quality: typeof QUALITY_OPTIONS[0]) => {
|
|
106
|
+
setSelectedQuality(quality);
|
|
107
|
+
setShowQualityMenu(false);
|
|
108
|
+
logEvent(`QUALITY: ${quality.label}`);
|
|
109
|
+
broadcast({ action: 'quality', quality: quality.value, ts: Date.now() });
|
|
110
|
+
}, [broadcast, logEvent]);
|
|
111
|
+
|
|
90
112
|
// Compute average delay
|
|
91
113
|
const avgDelay = delayHistoryRef.current.length > 0
|
|
92
114
|
? Math.round(delayHistoryRef.current.reduce((a: number, b: number) => a + b, 0) / delayHistoryRef.current.length)
|
|
@@ -142,10 +164,46 @@ export default function App() {
|
|
|
142
164
|
|
|
143
165
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
144
166
|
<div className="lg:col-span-2 space-y-8">
|
|
167
|
+
{/* Quality selector */}
|
|
168
|
+
<div className="flex items-center justify-between">
|
|
169
|
+
<div className="flex items-center gap-2">
|
|
170
|
+
<span className="px-2.5 py-1 rounded-lg bg-brandPurple/20 border border-brandPurple/30 text-brandPurple text-[11px] font-bold uppercase tracking-wider">
|
|
171
|
+
{selectedQuality.label}
|
|
172
|
+
</span>
|
|
173
|
+
<span className="text-xs text-textDim">Current Quality</span>
|
|
174
|
+
</div>
|
|
175
|
+
<div className="relative">
|
|
176
|
+
<button
|
|
177
|
+
onClick={() => setShowQualityMenu(!showQualityMenu)}
|
|
178
|
+
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg glass-panel text-xs text-textMuted hover:text-textMain transition-colors"
|
|
179
|
+
>
|
|
180
|
+
<Settings className="w-3.5 h-3.5" />
|
|
181
|
+
Quality
|
|
182
|
+
</button>
|
|
183
|
+
{showQualityMenu && (
|
|
184
|
+
<div className="absolute right-0 top-full mt-1 z-50 glass-panel rounded-xl overflow-hidden shadow-2xl border border-pulseBorder min-w-[120px]">
|
|
185
|
+
{QUALITY_OPTIONS.map((q: any) => (
|
|
186
|
+
<button
|
|
187
|
+
key={q.value}
|
|
188
|
+
onClick={() => handleQualityChange(q)}
|
|
189
|
+
className={`w-full px-4 py-2.5 text-left text-xs font-mono transition-colors ${selectedQuality.value === q.value
|
|
190
|
+
? 'bg-brandPurple/20 text-brandPurple font-bold'
|
|
191
|
+
: 'text-textMuted hover:bg-white/5 hover:text-textMain'
|
|
192
|
+
}`}
|
|
193
|
+
>
|
|
194
|
+
{q.label}
|
|
195
|
+
{selectedQuality.value === q.value && ' ✓'}
|
|
196
|
+
</button>
|
|
197
|
+
))}
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
145
203
|
<VideoPlayer
|
|
146
204
|
role="host"
|
|
147
205
|
label="Host Player (You control this)"
|
|
148
|
-
src={
|
|
206
|
+
src={selectedQuality.src}
|
|
149
207
|
currentTime={hostTime}
|
|
150
208
|
isPlaying={isPlaying}
|
|
151
209
|
onPlay={() => { setIsPlaying(true); broadcastAction('play', hostTime); }}
|
|
@@ -153,15 +211,17 @@ export default function App() {
|
|
|
153
211
|
onSeek={(t: number) => { setHostTime(t); broadcastAction('seek', t); }}
|
|
154
212
|
onTick={(t: number) => { setHostTime(t); broadcastAction('tick', t); }}
|
|
155
213
|
accentColor="#a5b4fc"
|
|
214
|
+
quality={selectedQuality.label}
|
|
156
215
|
/>
|
|
157
216
|
|
|
158
217
|
<VideoPlayer
|
|
159
218
|
role="peer"
|
|
160
219
|
label="Peer Viewer (Remote representation)"
|
|
161
|
-
src={
|
|
220
|
+
src={selectedQuality.src}
|
|
162
221
|
currentTime={peerTime}
|
|
163
222
|
isPlaying={isPlaying}
|
|
164
223
|
accentColor="#67e8f9"
|
|
224
|
+
quality={selectedQuality.label}
|
|
165
225
|
/>
|
|
166
226
|
</div>
|
|
167
227
|
|
|
@@ -11,6 +11,7 @@ interface VideoPlayerProps {
|
|
|
11
11
|
onSeek?: (time: number) => void;
|
|
12
12
|
onTick?: (time: number) => void;
|
|
13
13
|
accentColor: string;
|
|
14
|
+
quality?: string;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export function VideoPlayer({
|
|
@@ -23,7 +24,8 @@ export function VideoPlayer({
|
|
|
23
24
|
onPause,
|
|
24
25
|
onSeek,
|
|
25
26
|
onTick,
|
|
26
|
-
accentColor
|
|
27
|
+
accentColor,
|
|
28
|
+
quality
|
|
27
29
|
}: VideoPlayerProps) {
|
|
28
30
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
29
31
|
|
|
@@ -100,6 +102,11 @@ export function VideoPlayer({
|
|
|
100
102
|
</div>
|
|
101
103
|
|
|
102
104
|
<div className="relative rounded-2xl overflow-hidden border border-pulseBorder bg-black shadow-xl ring-1 ring-white/5 h-64 sm:h-80 md:h-96">
|
|
105
|
+
{quality && (
|
|
106
|
+
<div className="absolute top-3 right-3 z-10 px-2 py-1 rounded-md bg-black/70 border border-white/10 backdrop-blur-sm text-[10px] font-bold font-mono text-white/80 uppercase tracking-wider">
|
|
107
|
+
{quality}
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
103
110
|
<video
|
|
104
111
|
ref={videoRef}
|
|
105
112
|
controls={role === 'host'}
|