@sansavision/create-pulse 0.1.0-alpha.8 → 0.4.0
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/dist/index.js +1 -0
- package/package.json +2 -2
- package/src/index.ts +1 -0
- package/templates/react-all-features/package.json +26 -26
- package/templates/react-all-features/src/App.tsx +29 -19
- package/templates/react-all-features/src/components/EncryptedChat.tsx +31 -0
- package/templates/react-all-features/src/components/GameSync.tsx +41 -10
- package/templates/react-all-features/src/components/ServerMetrics.tsx +33 -6
- package/templates/react-queue-demo/README.md +44 -0
- package/templates/react-queue-demo/index.html +15 -0
- package/templates/react-queue-demo/package.json +22 -0
- package/templates/react-queue-demo/src/App.tsx +172 -0
- package/templates/react-queue-demo/src/main.tsx +4 -0
- package/templates/react-queue-demo/tsconfig.json +16 -0
- package/templates/react-queue-demo/vite.config.ts +6 -0
- package/templates/react-watch-together/package.json +26 -26
- package/templates/react-watch-together/src/App.tsx +93 -22
- package/templates/react-watch-together/src/components/VideoPlayer.tsx +8 -1
package/dist/index.js
CHANGED
|
@@ -53,6 +53,7 @@ async function main() {
|
|
|
53
53
|
options: [
|
|
54
54
|
{ value: "react-watch-together", label: "Watch Together (React + TS)", hint: "Synchronized video playback" },
|
|
55
55
|
{ value: "react-all-features", label: "All Features (React + TS)", hint: "Chat, Video, Audio, RPC" },
|
|
56
|
+
{ value: "react-queue-demo", label: "Durable Queues (React + TS)", hint: "Persistent queues with WAL/Postgres/Redis" },
|
|
56
57
|
{ value: "vanilla-basic", label: "Vanilla JS (Basic)", hint: "Minimal setup" }
|
|
57
58
|
]
|
|
58
59
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sansavision/create-pulse",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Scaffold a new Pulse application",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -29,4 +29,4 @@
|
|
|
29
29
|
"tsup": "^8.0.2",
|
|
30
30
|
"typescript": "^5.0.0"
|
|
31
31
|
}
|
|
32
|
-
}
|
|
32
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -35,6 +35,7 @@ async function main() {
|
|
|
35
35
|
options: [
|
|
36
36
|
{ value: 'react-watch-together', label: 'Watch Together (React + TS)', hint: 'Synchronized video playback' },
|
|
37
37
|
{ value: 'react-all-features', label: 'All Features (React + TS)', hint: 'Chat, Video, Audio, RPC' },
|
|
38
|
+
{ value: 'react-queue-demo', label: 'Durable Queues (React + TS)', hint: 'Persistent queues with WAL/Postgres/Redis' },
|
|
38
39
|
{ value: 'vanilla-basic', label: 'Vanilla JS (Basic)', hint: 'Minimal setup' }
|
|
39
40
|
]
|
|
40
41
|
});
|
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
2
|
+
"name": "pulse-react-all-features",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.4.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "tsc -b && vite build",
|
|
9
|
+
"preview": "vite preview"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@sansavision/pulse-sdk": "0.3.0",
|
|
13
|
+
"react": "^18.3.1",
|
|
14
|
+
"react-dom": "^18.3.1",
|
|
15
|
+
"lucide-react": "^0.412.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/react": "^18.3.3",
|
|
19
|
+
"@types/react-dom": "^18.3.0",
|
|
20
|
+
"@vitejs/plugin-react": "^4.3.1",
|
|
21
|
+
"autoprefixer": "^10.4.19",
|
|
22
|
+
"postcss": "^8.4.39",
|
|
23
|
+
"tailwindcss": "^3.4.6",
|
|
24
|
+
"typescript": "^5.5.3",
|
|
25
|
+
"vite": "^5.3.4"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -73,25 +73,35 @@ export default function App() {
|
|
|
73
73
|
const msg = { action, time, ts: sendTs };
|
|
74
74
|
broadcast(msg);
|
|
75
75
|
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
76
|
+
// Single-tab simulation: defer peerTime update by the measured relay
|
|
77
|
+
// round-trip so that drift is visible and realistic. In a real
|
|
78
|
+
// multi-tab setup the relay echo handler (handleSyncMessage) updates
|
|
79
|
+
// peerTime — but since the relay does not echo back to the sender,
|
|
80
|
+
// we simulate it here with the actual measured delay.
|
|
81
|
+
const simulatedDelay = delayHistoryRef.current.length > 0
|
|
82
|
+
? delayHistoryRef.current[delayHistoryRef.current.length - 1]
|
|
83
|
+
: 4; // sensible default ~4ms first message
|
|
84
|
+
|
|
85
|
+
setTimeout(() => {
|
|
86
|
+
if (action === 'tick') {
|
|
87
|
+
setPeerTime(time);
|
|
88
|
+
} else if (action === 'play') {
|
|
89
|
+
setIsPlaying(true);
|
|
90
|
+
setPeerTime(time);
|
|
91
|
+
} else if (action === 'pause') {
|
|
92
|
+
setIsPlaying(false);
|
|
93
|
+
setPeerTime(time);
|
|
94
|
+
} else if (action === 'seek') {
|
|
95
|
+
setPeerTime(time);
|
|
96
|
+
}
|
|
97
|
+
}, simulatedDelay);
|
|
98
|
+
|
|
99
|
+
// Measure delay after the event loop processes the send
|
|
100
|
+
setTimeout(() => {
|
|
101
|
+
const delay = Date.now() - sendTs;
|
|
102
|
+
setLastDelay(delay);
|
|
103
|
+
delayHistoryRef.current = [...delayHistoryRef.current.slice(-29), delay];
|
|
104
|
+
}, 0);
|
|
95
105
|
}, [broadcast, logEvent]);
|
|
96
106
|
|
|
97
107
|
// Computed delay stats
|
|
@@ -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,14 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
|
|
|
189
207
|
// Send to peers
|
|
190
208
|
broadcast(payload);
|
|
191
209
|
|
|
210
|
+
// Delay is measured in handleSyncMessage when the relay echoes back.
|
|
211
|
+
// Also measure locally via deferred setTimeout for single-tab demos.
|
|
212
|
+
setTimeout(() => {
|
|
213
|
+
const delay = Date.now() - sendTs;
|
|
214
|
+
setLastDelay(delay);
|
|
215
|
+
delayHistoryRef.current = [...delayHistoryRef.current.slice(-29), delay];
|
|
216
|
+
}, 0);
|
|
217
|
+
|
|
192
218
|
// Interpolate fallback for when broadcast doesn't echo locally fast enough
|
|
193
219
|
const rv = remoteViewRef.current;
|
|
194
220
|
rv.p1.x += (localP.x - rv.p1.x) * 0.1;
|
|
@@ -249,18 +275,23 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
|
|
|
249
275
|
<div className="text-[10px] text-textDim uppercase tracking-widest mt-1">Updates/sec</div>
|
|
250
276
|
</div>
|
|
251
277
|
<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">
|
|
278
|
+
<div className="text-2xl font-bold font-mono text-brandPurple">{metrics.bytes}</div>
|
|
279
|
+
<div className="text-[10px] text-textDim uppercase tracking-widest mt-1">Bytes/sec</div>
|
|
254
280
|
</div>
|
|
255
281
|
<div className="glass-panel p-4 text-center">
|
|
256
|
-
<div className=
|
|
257
|
-
|
|
282
|
+
<div className={`text-2xl font-bold font-mono ${lastDelay !== null && lastDelay > 50 ? 'text-brandAmber' : 'text-brandEmerald'}`}>
|
|
283
|
+
{lastDelay !== null ? `${lastDelay}ms` : '—'}
|
|
284
|
+
</div>
|
|
285
|
+
<div className="text-[10px] text-textDim uppercase tracking-widest mt-1">Msg Delay</div>
|
|
258
286
|
</div>
|
|
259
287
|
<div className="glass-panel p-4 text-center">
|
|
260
|
-
<div className="text-2xl font-bold font-mono text-
|
|
261
|
-
|
|
288
|
+
<div className="text-2xl font-bold font-mono text-brandEmerald">
|
|
289
|
+
{avgDelay !== null ? `~${avgDelay * 2}ms` : '—'}
|
|
290
|
+
</div>
|
|
291
|
+
<div className="text-[10px] text-textDim uppercase tracking-widest mt-1">Relay RTT</div>
|
|
262
292
|
</div>
|
|
263
293
|
</div>
|
|
264
294
|
</div>
|
|
265
295
|
);
|
|
266
296
|
}
|
|
297
|
+
|
|
@@ -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,33 @@ 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
|
+
// Measure delay via deferred setTimeout for single-tab demos
|
|
105
|
+
setTimeout(() => {
|
|
106
|
+
const delay = Date.now() - sendTs;
|
|
107
|
+
setLastDelay(delay);
|
|
108
|
+
delayHistoryRef.current = [...delayHistoryRef.current.slice(-29), delay];
|
|
109
|
+
}, 0);
|
|
91
110
|
}, 1000);
|
|
92
111
|
|
|
93
112
|
return () => clearInterval(interval);
|
|
94
113
|
}, [broadcast]);
|
|
95
114
|
|
|
115
|
+
const avgDelay = delayHistoryRef.current.length > 0
|
|
116
|
+
? Math.round(delayHistoryRef.current.reduce((a: number, b: number) => a + b, 0) / delayHistoryRef.current.length)
|
|
117
|
+
: null;
|
|
118
|
+
|
|
96
119
|
return (
|
|
97
120
|
<div className="space-y-6 max-w-5xl mx-auto">
|
|
98
121
|
<div className="grid grid-cols-4 gap-4">
|
|
@@ -105,12 +128,16 @@ export function ServerMetrics({ useChannel, logEvent }: ServerMetricsProps) {
|
|
|
105
128
|
<div className="text-3xl font-bold text-brandPurple font-mono">{stats.mem.toFixed(1)}%</div>
|
|
106
129
|
</div>
|
|
107
130
|
<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=
|
|
131
|
+
<div className="text-[10px] text-textDim uppercase tracking-widest mb-2 font-semibold">Msg Delay</div>
|
|
132
|
+
<div className={`text-3xl font-bold font-mono ${lastDelay !== null && lastDelay > 50 ? 'text-brandAmber' : 'text-brandEmerald'}`}>
|
|
133
|
+
{lastDelay !== null ? `${lastDelay}ms` : '—'}
|
|
134
|
+
</div>
|
|
110
135
|
</div>
|
|
111
136
|
<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-
|
|
137
|
+
<div className="text-[10px] text-textDim uppercase tracking-widest mb-2 font-semibold">Relay RTT</div>
|
|
138
|
+
<div className="text-3xl font-bold text-brandEmerald font-mono">
|
|
139
|
+
{avgDelay !== null ? `~${avgDelay * 2}ms` : '—'}
|
|
140
|
+
</div>
|
|
114
141
|
</div>
|
|
115
142
|
</div>
|
|
116
143
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Pulse Queue Demo
|
|
2
|
+
|
|
3
|
+
A demo app showcasing **Pulse v0.4.0** durable message queues.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Publish → Queue → Consumer flow over PLP
|
|
8
|
+
- At-least-once delivery with ACK/NACK
|
|
9
|
+
- Dead-letter queue for failed messages
|
|
10
|
+
- Real-time RTT metrics
|
|
11
|
+
|
|
12
|
+
## Quick Start
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install
|
|
16
|
+
npm run dev
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
In a separate terminal, start Pulse with your preferred queue backend:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# In-memory (default, ephemeral)
|
|
23
|
+
pulse dev
|
|
24
|
+
|
|
25
|
+
# WAL (Write-Ahead Log — crash-resilient)
|
|
26
|
+
PULSE_QUEUE_BACKEND=wal pulse dev
|
|
27
|
+
|
|
28
|
+
# PostgreSQL
|
|
29
|
+
PULSE_QUEUE_BACKEND=postgres PULSE_QUEUE_POSTGRES_URL=postgres://user:pass@localhost/pulse pulse dev
|
|
30
|
+
|
|
31
|
+
# Redis
|
|
32
|
+
PULSE_QUEUE_BACKEND=redis PULSE_QUEUE_REDIS_URL=redis://localhost:6379 pulse dev
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Encryption at Rest
|
|
36
|
+
|
|
37
|
+
Add encryption to any backend:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
export PULSE_QUEUE_KEY=$(openssl rand -hex 32)
|
|
41
|
+
PULSE_QUEUE_BACKEND=wal pulse dev
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
All payloads are encrypted with ChaCha20-Poly1305 before reaching storage.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Pulse Queue Demo</title>
|
|
7
|
+
<style>
|
|
8
|
+
body { margin: 0; background: #0f0f23; color: #e5e7eb; }
|
|
9
|
+
</style>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="root"></div>
|
|
13
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pulse-queue-demo",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"build": "tsc && vite build",
|
|
8
|
+
"preview": "vite preview"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@sansavision/pulse-sdk": "^0.4.0",
|
|
12
|
+
"react": "^19.0.0",
|
|
13
|
+
"react-dom": "^19.0.0"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/react": "^19.0.0",
|
|
17
|
+
"@types/react-dom": "^19.0.0",
|
|
18
|
+
"@vitejs/plugin-react": "^4.0.0",
|
|
19
|
+
"typescript": "^5.7.0",
|
|
20
|
+
"vite": "^6.0.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
2
|
+
import { Pulse } from '@sansavision/pulse-sdk'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pulse v0.4.0 — Durable Queue Demo
|
|
6
|
+
*
|
|
7
|
+
* This template demonstrates the persistent message queue system with:
|
|
8
|
+
* - Publisher → Queue → Consumer flow over PLP
|
|
9
|
+
* - At-least-once delivery semantics
|
|
10
|
+
* - Dead-letter queue visibility
|
|
11
|
+
* - Real-time metrics (RTT, packet loss)
|
|
12
|
+
*
|
|
13
|
+
* Server-side queue backends (configured via PULSE_QUEUE_BACKEND env var):
|
|
14
|
+
* - memory: In-memory (default, ephemeral)
|
|
15
|
+
* - wal: Write-Ahead Log (crash-resilient, local filesystem)
|
|
16
|
+
* - postgres: PostgreSQL (cloud/HA)
|
|
17
|
+
* - redis: Redis (shared state / cache)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
interface QueueMessage {
|
|
21
|
+
id: string
|
|
22
|
+
payload: string
|
|
23
|
+
timestamp: number
|
|
24
|
+
state: 'pending' | 'in_flight' | 'acked' | 'dead_lettered'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default function App() {
|
|
28
|
+
const [connected, setConnected] = useState(false)
|
|
29
|
+
const [messages, setMessages] = useState<QueueMessage[]>([])
|
|
30
|
+
const [input, setInput] = useState('')
|
|
31
|
+
const [rtt, setRtt] = useState<number | null>(null)
|
|
32
|
+
const [conn, setConn] = useState<any>(null)
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const pulse = new Pulse({ apiKey: 'dev' })
|
|
36
|
+
|
|
37
|
+
pulse.connect('ws://localhost:4001').then((connection) => {
|
|
38
|
+
setConn(connection)
|
|
39
|
+
setConnected(true)
|
|
40
|
+
|
|
41
|
+
// Subscribe to queue stream
|
|
42
|
+
const stream = connection.stream('task-queue', { qos: 'durable' })
|
|
43
|
+
stream.on('data', (bytes: Uint8Array) => {
|
|
44
|
+
const msg = JSON.parse(new TextDecoder().decode(bytes))
|
|
45
|
+
setMessages((prev) => [...prev, msg])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// Live metrics
|
|
49
|
+
connection.on('metrics', (m: any) => setRtt(m.rtt))
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
return () => {
|
|
53
|
+
conn?.close()
|
|
54
|
+
}
|
|
55
|
+
}, [])
|
|
56
|
+
|
|
57
|
+
const publish = useCallback(async () => {
|
|
58
|
+
if (!conn || !input.trim()) return
|
|
59
|
+
|
|
60
|
+
const msg: QueueMessage = {
|
|
61
|
+
id: crypto.randomUUID(),
|
|
62
|
+
payload: input.trim(),
|
|
63
|
+
timestamp: Date.now(),
|
|
64
|
+
state: 'pending',
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const stream = conn.stream('task-queue', { qos: 'durable' })
|
|
68
|
+
stream.send(new TextEncoder().encode(JSON.stringify(msg)))
|
|
69
|
+
setMessages((prev) => [...prev, msg])
|
|
70
|
+
setInput('')
|
|
71
|
+
}, [conn, input])
|
|
72
|
+
|
|
73
|
+
const ackMessage = useCallback(async (id: string) => {
|
|
74
|
+
if (!conn) return
|
|
75
|
+
await conn.service('queue', 'ack', { queue: 'task-queue', message_id: id })
|
|
76
|
+
setMessages((prev) =>
|
|
77
|
+
prev.map((m) => (m.id === id ? { ...m, state: 'acked' as const } : m))
|
|
78
|
+
)
|
|
79
|
+
}, [conn])
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div style={{ fontFamily: 'Inter, system-ui, sans-serif', maxWidth: 720, margin: '0 auto', padding: 32 }}>
|
|
83
|
+
<h1>🔄 Pulse Queue Demo</h1>
|
|
84
|
+
<p style={{ color: '#888' }}>
|
|
85
|
+
Durable message queues with at-least-once delivery over PLP.
|
|
86
|
+
{connected ? (
|
|
87
|
+
<span style={{ color: '#4ade80' }}> ● Connected</span>
|
|
88
|
+
) : (
|
|
89
|
+
<span style={{ color: '#f87171' }}> ○ Connecting...</span>
|
|
90
|
+
)}
|
|
91
|
+
{rtt !== null && <span style={{ marginLeft: 12 }}>RTT: {rtt}ms</span>}
|
|
92
|
+
</p>
|
|
93
|
+
|
|
94
|
+
<div style={{ display: 'flex', gap: 8, marginBottom: 24 }}>
|
|
95
|
+
<input
|
|
96
|
+
value={input}
|
|
97
|
+
onChange={(e) => setInput(e.target.value)}
|
|
98
|
+
onKeyDown={(e) => e.key === 'Enter' && publish()}
|
|
99
|
+
placeholder="Enter a task message..."
|
|
100
|
+
style={{
|
|
101
|
+
flex: 1, padding: '10px 16px', borderRadius: 8,
|
|
102
|
+
border: '1px solid #333', background: '#1a1a2e', color: '#fff', fontSize: 14
|
|
103
|
+
}}
|
|
104
|
+
/>
|
|
105
|
+
<button
|
|
106
|
+
onClick={publish}
|
|
107
|
+
disabled={!connected}
|
|
108
|
+
style={{
|
|
109
|
+
padding: '10px 20px', borderRadius: 8, border: 'none',
|
|
110
|
+
background: '#7c3aed', color: '#fff', fontWeight: 600, cursor: 'pointer'
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
Publish
|
|
114
|
+
</button>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<h2>Messages ({messages.length})</h2>
|
|
118
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
119
|
+
{messages.map((msg) => (
|
|
120
|
+
<div
|
|
121
|
+
key={msg.id}
|
|
122
|
+
style={{
|
|
123
|
+
padding: 16, borderRadius: 8,
|
|
124
|
+
background: msg.state === 'acked' ? '#064e3b' : msg.state === 'dead_lettered' ? '#7f1d1d' : '#1e1b4b',
|
|
125
|
+
display: 'flex', justifyContent: 'space-between', alignItems: 'center'
|
|
126
|
+
}}
|
|
127
|
+
>
|
|
128
|
+
<div>
|
|
129
|
+
<strong>{msg.payload}</strong>
|
|
130
|
+
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
|
131
|
+
{msg.id.slice(0, 8)} · {new Date(msg.timestamp).toLocaleTimeString()} ·{' '}
|
|
132
|
+
<span style={{
|
|
133
|
+
color: msg.state === 'acked' ? '#4ade80' : msg.state === 'dead_lettered' ? '#f87171' : '#facc15'
|
|
134
|
+
}}>
|
|
135
|
+
{msg.state}
|
|
136
|
+
</span>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
{msg.state === 'pending' && (
|
|
140
|
+
<button
|
|
141
|
+
onClick={() => ackMessage(msg.id)}
|
|
142
|
+
style={{
|
|
143
|
+
padding: '6px 12px', borderRadius: 6, border: 'none',
|
|
144
|
+
background: '#22c55e', color: '#000', fontWeight: 600, cursor: 'pointer'
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
ACK
|
|
148
|
+
</button>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
))}
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<div style={{ marginTop: 32, padding: 16, borderRadius: 8, background: '#111827', fontSize: 13, color: '#9ca3af' }}>
|
|
155
|
+
<strong style={{ color: '#e5e7eb' }}>Server Configuration</strong>
|
|
156
|
+
<pre style={{ marginTop: 8 }}>
|
|
157
|
+
{`# Start Pulse with WAL queue persistence:
|
|
158
|
+
PULSE_QUEUE_BACKEND=wal PULSE_QUEUE_WAL_DIR=./data pulse dev
|
|
159
|
+
|
|
160
|
+
# Or with PostgreSQL:
|
|
161
|
+
PULSE_QUEUE_BACKEND=postgres PULSE_QUEUE_POSTGRES_URL=postgres://... pulse dev
|
|
162
|
+
|
|
163
|
+
# Or with Redis:
|
|
164
|
+
PULSE_QUEUE_BACKEND=redis PULSE_QUEUE_REDIS_URL=redis://localhost:6379 pulse dev
|
|
165
|
+
|
|
166
|
+
# Enable encryption at rest (any backend):
|
|
167
|
+
PULSE_QUEUE_KEY=$(openssl rand -hex 32) pulse dev`}
|
|
168
|
+
</pre>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"moduleResolution": "bundler",
|
|
9
|
+
"resolveJsonModule": true,
|
|
10
|
+
"isolatedModules": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"jsx": "react-jsx",
|
|
13
|
+
"strict": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src"]
|
|
16
|
+
}
|
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
2
|
+
"name": "pulse-react-watch-together",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.4.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "tsc -b && vite build",
|
|
9
|
+
"preview": "vite preview"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@sansavision/pulse-sdk": "0.3.0",
|
|
13
|
+
"react": "^18.3.1",
|
|
14
|
+
"react-dom": "^18.3.1",
|
|
15
|
+
"lucide-react": "^0.412.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/react": "^18.3.3",
|
|
19
|
+
"@types/react-dom": "^18.3.0",
|
|
20
|
+
"@vitejs/plugin-react": "^4.3.1",
|
|
21
|
+
"autoprefixer": "^10.4.19",
|
|
22
|
+
"postcss": "^8.4.39",
|
|
23
|
+
"tailwindcss": "^3.4.6",
|
|
24
|
+
"typescript": "^5.5.3",
|
|
25
|
+
"vite": "^5.3.4"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -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
|
|
|
@@ -66,25 +81,43 @@ export default function App() {
|
|
|
66
81
|
const msg = { action, time, ts: sendTs };
|
|
67
82
|
broadcast(msg);
|
|
68
83
|
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
// Single-tab simulation: defer peerTime update by the measured relay
|
|
85
|
+
// round-trip so that drift is visible and realistic. In a real
|
|
86
|
+
// multi-tab setup the relay echo handler (handleSyncMessage) updates
|
|
87
|
+
// peerTime — but since the relay does not echo back to the sender,
|
|
88
|
+
// we simulate it here with the actual measured delay.
|
|
89
|
+
const simulatedDelay = delayHistoryRef.current.length > 0
|
|
90
|
+
? delayHistoryRef.current[delayHistoryRef.current.length - 1]
|
|
91
|
+
: 4; // sensible default ~4ms first message
|
|
92
|
+
|
|
93
|
+
setTimeout(() => {
|
|
94
|
+
if (action === 'tick') {
|
|
95
|
+
setPeerTime(time);
|
|
96
|
+
} else if (action === 'play') {
|
|
97
|
+
setIsPlaying(true);
|
|
98
|
+
setPeerTime(time);
|
|
99
|
+
} else if (action === 'pause') {
|
|
100
|
+
setIsPlaying(false);
|
|
101
|
+
setPeerTime(time);
|
|
102
|
+
} else if (action === 'seek') {
|
|
103
|
+
setPeerTime(time);
|
|
104
|
+
}
|
|
105
|
+
}, simulatedDelay);
|
|
83
106
|
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
107
|
+
// Measure delay after the event loop processes the send (captures
|
|
108
|
+
// real serialization + WebSocket write time, not synchronous 0).
|
|
109
|
+
setTimeout(() => {
|
|
110
|
+
const delay = Date.now() - sendTs;
|
|
111
|
+
setLastDelay(delay);
|
|
112
|
+
delayHistoryRef.current = [...delayHistoryRef.current.slice(-29), delay];
|
|
113
|
+
}, 0);
|
|
114
|
+
}, [broadcast, logEvent]);
|
|
115
|
+
|
|
116
|
+
const handleQualityChange = useCallback((quality: typeof QUALITY_OPTIONS[0]) => {
|
|
117
|
+
setSelectedQuality(quality);
|
|
118
|
+
setShowQualityMenu(false);
|
|
119
|
+
logEvent(`QUALITY: ${quality.label}`);
|
|
120
|
+
broadcast({ action: 'quality', quality: quality.value, ts: Date.now() });
|
|
88
121
|
}, [broadcast, logEvent]);
|
|
89
122
|
|
|
90
123
|
// Compute average delay
|
|
@@ -142,10 +175,46 @@ export default function App() {
|
|
|
142
175
|
|
|
143
176
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
144
177
|
<div className="lg:col-span-2 space-y-8">
|
|
178
|
+
{/* Quality selector */}
|
|
179
|
+
<div className="flex items-center justify-between">
|
|
180
|
+
<div className="flex items-center gap-2">
|
|
181
|
+
<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">
|
|
182
|
+
{selectedQuality.label}
|
|
183
|
+
</span>
|
|
184
|
+
<span className="text-xs text-textDim">Current Quality</span>
|
|
185
|
+
</div>
|
|
186
|
+
<div className="relative">
|
|
187
|
+
<button
|
|
188
|
+
onClick={() => setShowQualityMenu(!showQualityMenu)}
|
|
189
|
+
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"
|
|
190
|
+
>
|
|
191
|
+
<Settings className="w-3.5 h-3.5" />
|
|
192
|
+
Quality
|
|
193
|
+
</button>
|
|
194
|
+
{showQualityMenu && (
|
|
195
|
+
<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]">
|
|
196
|
+
{QUALITY_OPTIONS.map((q: any) => (
|
|
197
|
+
<button
|
|
198
|
+
key={q.value}
|
|
199
|
+
onClick={() => handleQualityChange(q)}
|
|
200
|
+
className={`w-full px-4 py-2.5 text-left text-xs font-mono transition-colors ${selectedQuality.value === q.value
|
|
201
|
+
? 'bg-brandPurple/20 text-brandPurple font-bold'
|
|
202
|
+
: 'text-textMuted hover:bg-white/5 hover:text-textMain'
|
|
203
|
+
}`}
|
|
204
|
+
>
|
|
205
|
+
{q.label}
|
|
206
|
+
{selectedQuality.value === q.value && ' ✓'}
|
|
207
|
+
</button>
|
|
208
|
+
))}
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
145
214
|
<VideoPlayer
|
|
146
215
|
role="host"
|
|
147
216
|
label="Host Player (You control this)"
|
|
148
|
-
src={
|
|
217
|
+
src={selectedQuality.src}
|
|
149
218
|
currentTime={hostTime}
|
|
150
219
|
isPlaying={isPlaying}
|
|
151
220
|
onPlay={() => { setIsPlaying(true); broadcastAction('play', hostTime); }}
|
|
@@ -153,15 +222,17 @@ export default function App() {
|
|
|
153
222
|
onSeek={(t: number) => { setHostTime(t); broadcastAction('seek', t); }}
|
|
154
223
|
onTick={(t: number) => { setHostTime(t); broadcastAction('tick', t); }}
|
|
155
224
|
accentColor="#a5b4fc"
|
|
225
|
+
quality={selectedQuality.label}
|
|
156
226
|
/>
|
|
157
227
|
|
|
158
228
|
<VideoPlayer
|
|
159
229
|
role="peer"
|
|
160
230
|
label="Peer Viewer (Remote representation)"
|
|
161
|
-
src={
|
|
231
|
+
src={selectedQuality.src}
|
|
162
232
|
currentTime={peerTime}
|
|
163
233
|
isPlaying={isPlaying}
|
|
164
234
|
accentColor="#67e8f9"
|
|
235
|
+
quality={selectedQuality.label}
|
|
165
236
|
/>
|
|
166
237
|
</div>
|
|
167
238
|
|
|
@@ -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'}
|