@sansavision/create-pulse 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +115 -0
- package/package.json +32 -0
- package/src/index.ts +114 -0
- package/templates/react-all-features/index.html +12 -0
- package/templates/react-all-features/package-lock.json +2683 -0
- package/templates/react-all-features/package.json +27 -0
- package/templates/react-all-features/src/App.tsx +201 -0
- package/templates/react-all-features/src/components/VideoPlayer.tsx +113 -0
- package/templates/react-all-features/src/hooks/usePulse.ts +113 -0
- package/templates/react-all-features/src/index.css +38 -0
- package/templates/react-all-features/src/main.tsx +10 -0
- package/templates/react-all-features/tailwind.config.js +55 -0
- package/templates/react-all-features/tsconfig.json +25 -0
- package/templates/react-all-features/tsconfig.node.json +11 -0
- package/templates/react-all-features/tsconfig.node.tsbuildinfo +1 -0
- package/templates/react-all-features/tsconfig.tsbuildinfo +1 -0
- package/templates/react-all-features/vite.config.d.ts +2 -0
- package/templates/react-all-features/vite.config.js +6 -0
- package/templates/react-all-features/vite.config.ts +7 -0
- package/templates/react-watch-together/index.html +12 -0
- package/templates/react-watch-together/package-lock.json +2683 -0
- package/templates/react-watch-together/package.json +27 -0
- package/templates/react-watch-together/src/App.tsx +165 -0
- package/templates/react-watch-together/src/components/VideoPlayer.tsx +113 -0
- package/templates/react-watch-together/src/hooks/usePulse.ts +113 -0
- package/templates/react-watch-together/src/index.css +38 -0
- package/templates/react-watch-together/src/main.tsx +10 -0
- package/templates/react-watch-together/tailwind.config.js +55 -0
- package/templates/react-watch-together/tsconfig.json +25 -0
- package/templates/react-watch-together/tsconfig.node.json +11 -0
- package/templates/react-watch-together/tsconfig.node.tsbuildinfo +1 -0
- package/templates/react-watch-together/tsconfig.tsbuildinfo +1 -0
- package/templates/react-watch-together/vite.config.d.ts +2 -0
- package/templates/react-watch-together/vite.config.js +6 -0
- package/templates/react-watch-together/vite.config.ts +7 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pulse-react-all-features",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.1.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": "latest",
|
|
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
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react';
|
|
2
|
+
import { usePulse } from './hooks/usePulse';
|
|
3
|
+
import { VideoPlayer } from './components/VideoPlayer';
|
|
4
|
+
import { Activity, Settings2, Users, MessageSquare, Video, LineChart } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
const VIDEO_URL = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
|
|
7
|
+
const STREAM_NAME = "watch-together-demo";
|
|
8
|
+
|
|
9
|
+
export default function App() {
|
|
10
|
+
const { isConnected, isConnecting, metrics, useChannel } = usePulse({
|
|
11
|
+
relayUrl: 'ws://localhost:4001',
|
|
12
|
+
apiKey: 'demo'
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const [activeTab, setActiveTab] = useState<'watch' | 'chat' | 'metrics'>('watch');
|
|
16
|
+
|
|
17
|
+
const [hostTime, setHostTime] = useState(0);
|
|
18
|
+
const [peerTime, setPeerTime] = useState(0);
|
|
19
|
+
const [isPlaying, setIsPlaying] = useState(false);
|
|
20
|
+
const [events, setEvents] = useState<{ id: number, action: string, time: string }[]>([]);
|
|
21
|
+
|
|
22
|
+
const logEvent = useCallback((action: string) => {
|
|
23
|
+
setEvents(prev => [...prev.slice(-4), {
|
|
24
|
+
id: Date.now(),
|
|
25
|
+
action,
|
|
26
|
+
time: new Date().toISOString().substring(11, 23)
|
|
27
|
+
}]);
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
const handleSyncMessage = useCallback((msg: any) => {
|
|
31
|
+
if (activeTab !== 'watch') return;
|
|
32
|
+
logEvent(`RECV: ${msg.action}`);
|
|
33
|
+
|
|
34
|
+
switch (msg.action) {
|
|
35
|
+
case 'play':
|
|
36
|
+
setIsPlaying(true);
|
|
37
|
+
if (msg.time !== undefined) setPeerTime(msg.time);
|
|
38
|
+
break;
|
|
39
|
+
case 'pause':
|
|
40
|
+
setIsPlaying(false);
|
|
41
|
+
if (msg.time !== undefined) setPeerTime(msg.time);
|
|
42
|
+
break;
|
|
43
|
+
case 'seek':
|
|
44
|
+
if (msg.time !== undefined) setPeerTime(msg.time);
|
|
45
|
+
break;
|
|
46
|
+
case 'tick':
|
|
47
|
+
if (msg.time !== undefined) setPeerTime(msg.time);
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}, [logEvent, activeTab]);
|
|
51
|
+
|
|
52
|
+
// Connect to the synchronized channel
|
|
53
|
+
const { broadcast } = useChannel(STREAM_NAME, handleSyncMessage);
|
|
54
|
+
|
|
55
|
+
const broadcastAction = useCallback((action: string, time: number) => {
|
|
56
|
+
logEvent(`SEND: ${action}`);
|
|
57
|
+
broadcast({ action, time, ts: Date.now() });
|
|
58
|
+
}, [broadcast, logEvent]);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className="max-w-6xl mx-auto p-6 space-y-8">
|
|
62
|
+
{/* Header */}
|
|
63
|
+
<header className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 pb-6 border-b">
|
|
64
|
+
<div className="flex flex-col gap-2">
|
|
65
|
+
<div className="flex items-center gap-3">
|
|
66
|
+
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center shadow-lg shadow-indigo-500/20">
|
|
67
|
+
<Activity className="text-white w-6 h-6" />
|
|
68
|
+
</div>
|
|
69
|
+
<div>
|
|
70
|
+
<h1 className="text-2xl font-bold tracking-tight">Pulse Kitchen Sink</h1>
|
|
71
|
+
<p className="text-sm text-muted-foreground">All features integrated via Pulse SDK</p>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
<div className="flex gap-2 mt-4 border bg-background/50 rounded-lg p-1">
|
|
75
|
+
<button onClick={() => setActiveTab('watch')} className={`px-4 py-1.5 text-sm font-medium rounded-md flex items-center gap-2 transition-colors ${activeTab === 'watch' ? 'bg-primary text-primary-foreground shadow' : 'text-muted-foreground hover:bg-muted'}`}>
|
|
76
|
+
<Video className="w-4 h-4" /> Watch Together
|
|
77
|
+
</button>
|
|
78
|
+
<button onClick={() => setActiveTab('chat')} className={`px-4 py-1.5 text-sm font-medium rounded-md flex items-center gap-2 transition-colors ${activeTab === 'chat' ? 'bg-primary text-primary-foreground shadow' : 'text-muted-foreground hover:bg-muted'}`}>
|
|
79
|
+
<MessageSquare className="w-4 h-4" /> Chat (Stub)
|
|
80
|
+
</button>
|
|
81
|
+
<button onClick={() => setActiveTab('metrics')} className={`px-4 py-1.5 text-sm font-medium rounded-md flex items-center gap-2 transition-colors ${activeTab === 'metrics' ? 'bg-primary text-primary-foreground shadow' : 'text-muted-foreground hover:bg-muted'}`}>
|
|
82
|
+
<LineChart className="w-4 h-4" /> Metrics DB (Stub)
|
|
83
|
+
</button>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div className="flex gap-4 text-sm font-mono items-center bg-card px-4 py-2 rounded-lg border shadow-sm">
|
|
88
|
+
<div className="flex items-center gap-2">
|
|
89
|
+
<div className={`w-2.5 h-2.5 rounded-full ${isConnected ? 'bg-emerald-500' : isConnecting ? 'bg-amber-500' : 'bg-red-500'}`} />
|
|
90
|
+
<span className={isConnected ? 'text-emerald-500' : 'text-muted-foreground'}>
|
|
91
|
+
{isConnected ? 'Relay Connected' : isConnecting ? 'Connecting...' : 'Disconnected'}
|
|
92
|
+
</span>
|
|
93
|
+
</div>
|
|
94
|
+
{metrics && (
|
|
95
|
+
<div className="flex items-center gap-4 border-l pl-4 border-border/50 text-muted-foreground hidden sm:flex">
|
|
96
|
+
<span>RTT: <span className="text-foreground">{Math.round(metrics.rtt)}ms</span></span>
|
|
97
|
+
<span>Loss: <span className="text-foreground">{metrics.packetLoss}%</span></span>
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
</header>
|
|
102
|
+
|
|
103
|
+
<main>
|
|
104
|
+
{activeTab === 'watch' && (
|
|
105
|
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
106
|
+
<div className="lg:col-span-2 space-y-8">
|
|
107
|
+
<VideoPlayer
|
|
108
|
+
role="host"
|
|
109
|
+
label="Host Player (You control this)"
|
|
110
|
+
src={VIDEO_URL}
|
|
111
|
+
currentTime={hostTime}
|
|
112
|
+
isPlaying={isPlaying}
|
|
113
|
+
onPlay={() => { setIsPlaying(true); broadcastAction('play', hostTime); }}
|
|
114
|
+
onPause={() => { setIsPlaying(false); broadcastAction('pause', hostTime); }}
|
|
115
|
+
onSeek={(t) => { setHostTime(t); broadcastAction('seek', t); }}
|
|
116
|
+
onTick={(t) => { setHostTime(t); broadcastAction('tick', t); }}
|
|
117
|
+
accentColor="#8B5CF6"
|
|
118
|
+
/>
|
|
119
|
+
|
|
120
|
+
<VideoPlayer
|
|
121
|
+
role="peer"
|
|
122
|
+
label="Peer Viewer (Remote representation)"
|
|
123
|
+
src={VIDEO_URL}
|
|
124
|
+
currentTime={peerTime}
|
|
125
|
+
isPlaying={isPlaying}
|
|
126
|
+
accentColor="#06B6D4"
|
|
127
|
+
/>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<aside className="space-y-6">
|
|
131
|
+
<div className="p-5 rounded-xl border bg-card shadow-sm space-y-4">
|
|
132
|
+
<div className="flex items-center gap-2 font-medium text-foreground tracking-tight border-b pb-3">
|
|
133
|
+
<Settings2 className="w-4 h-4 text-primary" />
|
|
134
|
+
Sync Telemetry
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<div className="space-y-3 font-mono text-xs">
|
|
138
|
+
<div className="flex justify-between items-center p-2 rounded bg-background border">
|
|
139
|
+
<span className="text-muted-foreground">Host Time</span>
|
|
140
|
+
<span className="text-foreground">{hostTime.toFixed(2)}s</span>
|
|
141
|
+
</div>
|
|
142
|
+
<div className="flex justify-between items-center p-2 rounded bg-background border">
|
|
143
|
+
<span className="text-muted-foreground">Peer Time</span>
|
|
144
|
+
<span className="text-foreground">{peerTime.toFixed(2)}s</span>
|
|
145
|
+
</div>
|
|
146
|
+
<div className="flex justify-between items-center p-2 rounded bg-background border">
|
|
147
|
+
<span className="text-muted-foreground">Drift</span>
|
|
148
|
+
<span className={`font-semibold ${Math.abs(hostTime - peerTime) > 0.15 ? 'text-amber-500' : 'text-emerald-500'}`}>
|
|
149
|
+
{Math.abs(hostTime - peerTime).toFixed(3)}s
|
|
150
|
+
</span>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
<div className="p-5 rounded-xl border bg-card shadow-sm space-y-4">
|
|
156
|
+
<div className="flex items-center gap-2 font-medium text-foreground tracking-tight border-b pb-3">
|
|
157
|
+
<Users className="w-4 h-4 text-primary" />
|
|
158
|
+
Pulse Event Log
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
<div className="flex flex-col gap-2 font-mono text-[11px]">
|
|
162
|
+
{events.length === 0 ? (
|
|
163
|
+
<div className="text-muted-foreground italic p-2">No events yet. Press play!</div>
|
|
164
|
+
) : events.map(ev => (
|
|
165
|
+
<div key={ev.id} className="flex gap-3 items-center p-2 rounded bg-background border">
|
|
166
|
+
<span className="text-muted-foreground shrink-0">{ev.time}</span>
|
|
167
|
+
<span className={`px-1.5 py-0.5 rounded font-bold uppercase tracking-wider ${ev.action.includes('SEND') ? 'bg-primary/20 text-primary' : 'bg-cyan-500/20 text-cyan-400'}`}>
|
|
168
|
+
{ev.action.split(':')[0]}
|
|
169
|
+
</span>
|
|
170
|
+
<span className="text-foreground truncate opacity-80">{ev.action.split(':')[1]}</span>
|
|
171
|
+
</div>
|
|
172
|
+
))}
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
</aside>
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
|
|
179
|
+
{activeTab === 'chat' && (
|
|
180
|
+
<div className="flex flex-col items-center justify-center p-12 border rounded-xl bg-card border-dashed">
|
|
181
|
+
<MessageSquare className="w-12 h-12 text-muted-foreground mb-4 opacity-50" />
|
|
182
|
+
<h3 className="text-lg font-medium">Chat Setup</h3>
|
|
183
|
+
<p className="text-muted-foreground text-center max-w-sm mt-2">
|
|
184
|
+
Implementation for the E2E encrypted chat over Pulse streams goes here. See <code className="bg-muted px-1 rounded">useChannel(Crypto)</code>.
|
|
185
|
+
</p>
|
|
186
|
+
</div>
|
|
187
|
+
)}
|
|
188
|
+
|
|
189
|
+
{activeTab === 'metrics' && (
|
|
190
|
+
<div className="flex flex-col items-center justify-center p-12 border rounded-xl bg-card border-dashed">
|
|
191
|
+
<LineChart className="w-12 h-12 text-muted-foreground mb-4 opacity-50" />
|
|
192
|
+
<h3 className="text-lg font-medium">Metrics Setup</h3>
|
|
193
|
+
<p className="text-muted-foreground text-center max-w-sm mt-2">
|
|
194
|
+
Implementation for real-time dashboard metrics (e.g. from Rust backend) goes here. See <code className="bg-muted px-1 rounded">useChannel('server-metrics')</code>.
|
|
195
|
+
</p>
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
</main>
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useRef, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
interface VideoPlayerProps {
|
|
4
|
+
label: string;
|
|
5
|
+
role: 'host' | 'peer';
|
|
6
|
+
src: string;
|
|
7
|
+
currentTime: number;
|
|
8
|
+
isPlaying: boolean;
|
|
9
|
+
onPlay?: () => void;
|
|
10
|
+
onPause?: () => void;
|
|
11
|
+
onSeek?: (time: number) => void;
|
|
12
|
+
onTick?: (time: number) => void;
|
|
13
|
+
accentColor: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function VideoPlayer({
|
|
17
|
+
label,
|
|
18
|
+
role,
|
|
19
|
+
src,
|
|
20
|
+
currentTime,
|
|
21
|
+
isPlaying,
|
|
22
|
+
onPlay,
|
|
23
|
+
onPause,
|
|
24
|
+
onSeek,
|
|
25
|
+
onTick,
|
|
26
|
+
accentColor
|
|
27
|
+
}: VideoPlayerProps) {
|
|
28
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
29
|
+
|
|
30
|
+
// Handle incoming remote state changes
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const video = videoRef.current;
|
|
33
|
+
if (!video) return;
|
|
34
|
+
|
|
35
|
+
if (role === 'peer') {
|
|
36
|
+
const drift = Math.abs(video.currentTime - currentTime);
|
|
37
|
+
// Only force seek if drift > 150ms to avoid audio stuttering
|
|
38
|
+
if (drift > 0.150) {
|
|
39
|
+
video.currentTime = currentTime;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (isPlaying && video.paused) {
|
|
43
|
+
video.play().catch(console.error);
|
|
44
|
+
} else if (!isPlaying && !video.paused) {
|
|
45
|
+
video.pause();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}, [currentTime, isPlaying, role]);
|
|
49
|
+
|
|
50
|
+
// Handle local user interactions (Host only)
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const video = videoRef.current;
|
|
53
|
+
if (!video || role !== 'host') return;
|
|
54
|
+
|
|
55
|
+
const handlePlay = () => onPlay?.();
|
|
56
|
+
const handlePause = () => onPause?.();
|
|
57
|
+
const handleSeek = () => {
|
|
58
|
+
// Small timeout allows the video time to update before broadcasting
|
|
59
|
+
setTimeout(() => onSeek?.(video.currentTime), 0);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
video.addEventListener('play', handlePlay);
|
|
63
|
+
video.addEventListener('pause', handlePause);
|
|
64
|
+
video.addEventListener('seeked', handleSeek);
|
|
65
|
+
|
|
66
|
+
return () => {
|
|
67
|
+
video.removeEventListener('play', handlePlay);
|
|
68
|
+
video.removeEventListener('pause', handlePause);
|
|
69
|
+
video.removeEventListener('seeked', handleSeek);
|
|
70
|
+
};
|
|
71
|
+
}, [role, onPlay, onPause, onSeek]);
|
|
72
|
+
|
|
73
|
+
// Periodic tick for Host to sync peers
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
const video = videoRef.current;
|
|
76
|
+
if (!video || role !== 'host' || !onTick) return;
|
|
77
|
+
|
|
78
|
+
const interval = setInterval(() => {
|
|
79
|
+
if (!video.paused) {
|
|
80
|
+
onTick(video.currentTime);
|
|
81
|
+
}
|
|
82
|
+
}, 500);
|
|
83
|
+
|
|
84
|
+
return () => clearInterval(interval);
|
|
85
|
+
}, [role, onTick]);
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div className="flex flex-col gap-2">
|
|
89
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
90
|
+
<div
|
|
91
|
+
className="w-2.5 h-2.5 rounded-full"
|
|
92
|
+
style={{ backgroundColor: accentColor }}
|
|
93
|
+
/>
|
|
94
|
+
{label}
|
|
95
|
+
{role === 'peer' && (
|
|
96
|
+
<span className="ml-auto px-2 mx-2 py-0.5 rounded-full border border-cyan-500/20 bg-cyan-500/10 text-cyan-400 text-xs font-mono">
|
|
97
|
+
Synced via Pulse
|
|
98
|
+
</span>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<div className="relative rounded-lg overflow-hidden border bg-black shadow-sm h-64 sm:h-80 md:h-96">
|
|
103
|
+
<video
|
|
104
|
+
ref={videoRef}
|
|
105
|
+
controls={role === 'host'}
|
|
106
|
+
className={`w-full h-full object-cover ${role === 'peer' ? 'pointer-events-none' : ''}`}
|
|
107
|
+
src={src}
|
|
108
|
+
crossOrigin="anonymous"
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
2
|
+
import { Pulse, PulseConnection, PulseStream, ConnectionMetrics } from '@sansavision/pulse-sdk';
|
|
3
|
+
|
|
4
|
+
interface UsePulseOptions {
|
|
5
|
+
relayUrl?: string;
|
|
6
|
+
apiKey?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function usePulse(options: UsePulseOptions = {}) {
|
|
10
|
+
const {
|
|
11
|
+
relayUrl = 'ws://localhost:4001',
|
|
12
|
+
apiKey = 'demo'
|
|
13
|
+
} = options;
|
|
14
|
+
|
|
15
|
+
const [isConnected, setIsConnected] = useState(false);
|
|
16
|
+
const [metrics, setMetrics] = useState<ConnectionMetrics | null>(null);
|
|
17
|
+
const [error, setError] = useState<Error | null>(null);
|
|
18
|
+
const [isConnecting, setIsConnecting] = useState(true);
|
|
19
|
+
|
|
20
|
+
const connectionRef = useRef<PulseConnection | null>(null);
|
|
21
|
+
|
|
22
|
+
// Establish connection on mount
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
let mounted = true;
|
|
25
|
+
const pulse = new Pulse({ apiKey });
|
|
26
|
+
|
|
27
|
+
setIsConnecting(true);
|
|
28
|
+
|
|
29
|
+
pulse.connect(relayUrl)
|
|
30
|
+
.then(conn => {
|
|
31
|
+
if (!mounted) {
|
|
32
|
+
conn.disconnect();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
connectionRef.current = conn;
|
|
37
|
+
setIsConnected(true);
|
|
38
|
+
setIsConnecting(false);
|
|
39
|
+
|
|
40
|
+
conn.on('metrics', (m) => {
|
|
41
|
+
if (mounted) setMetrics(m);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
conn.on('disconnect', () => {
|
|
45
|
+
if (mounted) setIsConnected(false);
|
|
46
|
+
});
|
|
47
|
+
})
|
|
48
|
+
.catch(err => {
|
|
49
|
+
if (mounted) {
|
|
50
|
+
setError(err);
|
|
51
|
+
setIsConnecting(false);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return () => {
|
|
56
|
+
mounted = false;
|
|
57
|
+
if (connectionRef.current) {
|
|
58
|
+
connectionRef.current.disconnect();
|
|
59
|
+
connectionRef.current = null;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}, [relayUrl, apiKey]);
|
|
63
|
+
|
|
64
|
+
// Helper to open a pub/sub or RPC stream and manage its lifecycle
|
|
65
|
+
const useChannel = useCallback((channelName: string, onMessage?: (data: any) => void) => {
|
|
66
|
+
const [stream, setStream] = useState<PulseStream | null>(null);
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!isConnected || !connectionRef.current) return;
|
|
70
|
+
|
|
71
|
+
const newStream = connectionRef.current.stream(channelName, {
|
|
72
|
+
qos: 'interactive-data' // Best for low-latency sync
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
setStream(newStream);
|
|
76
|
+
|
|
77
|
+
const handleData = (buf: Uint8Array) => {
|
|
78
|
+
if (!onMessage) return;
|
|
79
|
+
try {
|
|
80
|
+
const str = new TextDecoder().decode(buf);
|
|
81
|
+
onMessage(JSON.parse(str));
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error("Failed to decode channel message", err);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
newStream.on('data', handleData);
|
|
88
|
+
|
|
89
|
+
return () => {
|
|
90
|
+
newStream.off('data', handleData);
|
|
91
|
+
newStream.close();
|
|
92
|
+
};
|
|
93
|
+
}, [isConnected, channelName, onMessage]);
|
|
94
|
+
|
|
95
|
+
const broadcast = useCallback((data: any) => {
|
|
96
|
+
if (stream) {
|
|
97
|
+
const payload = new TextEncoder().encode(JSON.stringify(data));
|
|
98
|
+
stream.send(payload);
|
|
99
|
+
}
|
|
100
|
+
}, [stream]);
|
|
101
|
+
|
|
102
|
+
return { stream, broadcast };
|
|
103
|
+
}, [isConnected]);
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
isConnected,
|
|
107
|
+
isConnecting,
|
|
108
|
+
metrics,
|
|
109
|
+
error,
|
|
110
|
+
connection: connectionRef.current,
|
|
111
|
+
useChannel
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
@tailwind base;
|
|
2
|
+
@tailwind components;
|
|
3
|
+
@tailwind utilities;
|
|
4
|
+
|
|
5
|
+
@layer base {
|
|
6
|
+
:root {
|
|
7
|
+
--background: 240 10% 4%;
|
|
8
|
+
--foreground: 0 0% 98%;
|
|
9
|
+
--card: 240 10% 6%;
|
|
10
|
+
--card-foreground: 0 0% 98%;
|
|
11
|
+
--popover: 240 10% 4%;
|
|
12
|
+
--popover-foreground: 0 0% 98%;
|
|
13
|
+
--primary: 262 83% 58%;
|
|
14
|
+
--primary-foreground: 210 40% 98%;
|
|
15
|
+
--secondary: 240 4% 16%;
|
|
16
|
+
--secondary-foreground: 0 0% 98%;
|
|
17
|
+
--muted: 240 5% 26%;
|
|
18
|
+
--muted-foreground: 240 5% 65%;
|
|
19
|
+
--accent: 262 83% 58%;
|
|
20
|
+
--accent-foreground: 0 0% 98%;
|
|
21
|
+
--destructive: 0 62.8% 30.6%;
|
|
22
|
+
--destructive-foreground: 0 0% 98%;
|
|
23
|
+
--border: 240 6% 15%;
|
|
24
|
+
--input: 240 6% 15%;
|
|
25
|
+
--ring: 240 5% 65%;
|
|
26
|
+
--radius: 0.5rem;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@layer base {
|
|
31
|
+
* {
|
|
32
|
+
@apply border-border;
|
|
33
|
+
}
|
|
34
|
+
body {
|
|
35
|
+
@apply bg-background text-foreground bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-indigo-900/20 via-background to-background min-h-screen text-sm;
|
|
36
|
+
font-feature-settings: "rlig" 1, "calt" 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** @type {import('tailwindcss').Config} */
|
|
2
|
+
export default {
|
|
3
|
+
darkMode: ["class"],
|
|
4
|
+
content: [
|
|
5
|
+
'./pages/**/*.{ts,tsx}',
|
|
6
|
+
'./components/**/*.{ts,tsx}',
|
|
7
|
+
'./app/**/*.{ts,tsx}',
|
|
8
|
+
'./src/**/*.{ts,tsx}',
|
|
9
|
+
],
|
|
10
|
+
theme: {
|
|
11
|
+
extend: {
|
|
12
|
+
colors: {
|
|
13
|
+
border: "hsl(var(--border))",
|
|
14
|
+
input: "hsl(var(--input))",
|
|
15
|
+
ring: "hsl(var(--ring))",
|
|
16
|
+
background: "hsl(var(--background))",
|
|
17
|
+
foreground: "hsl(var(--foreground))",
|
|
18
|
+
primary: {
|
|
19
|
+
DEFAULT: "hsl(var(--primary))",
|
|
20
|
+
foreground: "hsl(var(--primary-foreground))",
|
|
21
|
+
},
|
|
22
|
+
secondary: {
|
|
23
|
+
DEFAULT: "hsl(var(--secondary))",
|
|
24
|
+
foreground: "hsl(var(--secondary-foreground))",
|
|
25
|
+
},
|
|
26
|
+
destructive: {
|
|
27
|
+
DEFAULT: "hsl(var(--destructive))",
|
|
28
|
+
foreground: "hsl(var(--destructive-foreground))",
|
|
29
|
+
},
|
|
30
|
+
muted: {
|
|
31
|
+
DEFAULT: "hsl(var(--muted))",
|
|
32
|
+
foreground: "hsl(var(--muted-foreground))",
|
|
33
|
+
},
|
|
34
|
+
accent: {
|
|
35
|
+
DEFAULT: "hsl(var(--accent))",
|
|
36
|
+
foreground: "hsl(var(--accent-foreground))",
|
|
37
|
+
},
|
|
38
|
+
popover: {
|
|
39
|
+
DEFAULT: "hsl(var(--popover))",
|
|
40
|
+
foreground: "hsl(var(--popover-foreground))",
|
|
41
|
+
},
|
|
42
|
+
card: {
|
|
43
|
+
DEFAULT: "hsl(var(--card))",
|
|
44
|
+
foreground: "hsl(var(--card-foreground))",
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
borderRadius: {
|
|
48
|
+
lg: "var(--radius)",
|
|
49
|
+
md: "calc(var(--radius) - 2px)",
|
|
50
|
+
sm: "calc(var(--radius) - 4px)",
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
plugins: [],
|
|
55
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
|
|
9
|
+
/* Bundler mode */
|
|
10
|
+
"moduleResolution": "bundler",
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"noEmit": true,
|
|
15
|
+
"jsx": "react-jsx",
|
|
16
|
+
|
|
17
|
+
/* Linting */
|
|
18
|
+
"strict": true,
|
|
19
|
+
"noUnusedLocals": true,
|
|
20
|
+
"noUnusedParameters": true,
|
|
21
|
+
"noFallthroughCasesInSwitch": true
|
|
22
|
+
},
|
|
23
|
+
"include": ["src"],
|
|
24
|
+
"references": [{ "path": "./tsconfig.node.json" }]
|
|
25
|
+
}
|