@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sansavision/create-pulse",
3
- "version": "0.1.0-alpha.8",
3
+ "version": "0.1.0-alpha.9",
4
4
  "description": "Scaffold a new Pulse application",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -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">60Hz</div>
253
- <div className="text-[10px] text-textDim uppercase tracking-widest mt-1">Target Sync</div>
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="text-2xl font-bold font-mono text-brandEmerald">{metrics.bytes}</div>
257
- <div className="text-[10px] text-textDim uppercase tracking-widest mt-1">Bytes/sec</div>
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-brandAmber">ON</div>
261
- <div className="text-[10px] text-textDim uppercase tracking-widest mt-1">Interpolation</div>
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
- // We handle it directly in handleMessage too (since broadcast goes up to the relay and back)
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">Network</div>
109
- <div className="text-3xl font-bold text-brandEmerald font-mono">{stats.net.toFixed(0)} Mbps</div>
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">Disk I/O</div>
113
- <div className="text-3xl font-bold text-brandAmber font-mono">{stats.disk.toFixed(1)}%</div>
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
- const VIDEO_URL = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
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={VIDEO_URL}
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={VIDEO_URL}
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'}