@akhilpulse/samadhaan-session-replay 0.2.0 → 0.2.2
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/src/SessionReplayProvider.tsx +82 -29
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akhilpulse/samadhaan-session-replay",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "React Native SDK for Samadhaan Guided Support — session recording, live remote screen-share, take-control, and guided-tour overlays.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"module": "src/index.ts",
|
|
@@ -35,7 +35,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
|
35
35
|
import { captureRef } from "react-native-view-shot";
|
|
36
36
|
|
|
37
37
|
const STORAGE_KEY = "@akhilpulse/samadhaan-session-replay/sessionId";
|
|
38
|
-
const SDK_VERSION = "0.2.
|
|
38
|
+
const SDK_VERSION = "0.2.2";
|
|
39
39
|
|
|
40
40
|
/** Default Samadhaan production endpoint. Override via `config.serverUrl` for staging. */
|
|
41
41
|
export const DEFAULT_SERVER_URL = "https://samadhaan.pulseenergy.io";
|
|
@@ -124,6 +124,8 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
124
124
|
}, [flushEvents]);
|
|
125
125
|
|
|
126
126
|
// ---- Session init / resume ----
|
|
127
|
+
// initTick is bumped whenever we recycle (sessionId went to null at runtime) so this effect re-fires.
|
|
128
|
+
const [initTick, setInitTick] = useState(0);
|
|
127
129
|
useEffect(() => {
|
|
128
130
|
let cancelled = false;
|
|
129
131
|
(async () => {
|
|
@@ -134,8 +136,17 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
134
136
|
const r = await fetch(`${serverUrl}/api/guided-support/sessions/${stored}/status`);
|
|
135
137
|
if (r.ok) {
|
|
136
138
|
const j = await r.json();
|
|
137
|
-
|
|
138
|
-
|
|
139
|
+
// If the stored session was already ended (server-side idle timeout while app
|
|
140
|
+
// was backgrounded), drop it and fall through to create a fresh one.
|
|
141
|
+
if (j.status === "ended") {
|
|
142
|
+
try { await AsyncStorage.removeItem(STORAGE_KEY); } catch {}
|
|
143
|
+
} else {
|
|
144
|
+
if (!cancelled) { setSessionId(stored); setStatus(j.status || "active"); setIngestToken(j.ingestToken); }
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
// 404 / not found — clear stale id and create fresh.
|
|
149
|
+
try { await AsyncStorage.removeItem(STORAGE_KEY); } catch {}
|
|
139
150
|
}
|
|
140
151
|
}
|
|
141
152
|
const res = await fetch(`${serverUrl}/api/guided-support/sessions`, {
|
|
@@ -164,7 +175,7 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
164
175
|
}
|
|
165
176
|
})();
|
|
166
177
|
return () => { cancelled = true; };
|
|
167
|
-
}, [config.appId, serverUrl, config.appVersion, config.user]);
|
|
178
|
+
}, [config.appId, serverUrl, config.appVersion, config.user, initTick]);
|
|
168
179
|
|
|
169
180
|
// ---- Keyboard tracking (iOS auto-pause) ----
|
|
170
181
|
useEffect(() => {
|
|
@@ -182,11 +193,12 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
182
193
|
if (r.ok) {
|
|
183
194
|
const j = await r.json();
|
|
184
195
|
if (j.status === "ended") {
|
|
185
|
-
// Server idle-ended this session. Drop it and
|
|
196
|
+
// Server idle-ended this session. Drop it and immediately recycle (init effect re-fires via initTick).
|
|
186
197
|
try { await AsyncStorage.removeItem(STORAGE_KEY); } catch {}
|
|
187
198
|
setSessionId(null);
|
|
188
199
|
setIngestToken(null);
|
|
189
200
|
setStatus("idle");
|
|
201
|
+
setInitTick(t => t + 1);
|
|
190
202
|
} else {
|
|
191
203
|
setStatus(j.status);
|
|
192
204
|
}
|
|
@@ -201,40 +213,56 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
201
213
|
useEffect(() => {
|
|
202
214
|
if (config.disableScreenshots || !sessionId) return;
|
|
203
215
|
let stopped = false;
|
|
204
|
-
|
|
216
|
+
// Live: ~16 fps target (60ms). Idle: 10s sample for replay.
|
|
217
|
+
// Capture itself takes time, so the loop self-paces — we schedule the next
|
|
218
|
+
// tick AFTER the previous capture/upload completes rather than at a fixed cadence.
|
|
219
|
+
const idleInterval = 10000;
|
|
220
|
+
const liveTargetMs = 60;
|
|
221
|
+
let lastUploadFrameAt = 0;
|
|
205
222
|
const tick = async () => {
|
|
206
223
|
if (stopped) return;
|
|
207
|
-
|
|
224
|
+
const startedAt = Date.now();
|
|
225
|
+
if (paused || keyboardVisible) {
|
|
226
|
+
setTimeout(tick, liveMode ? liveTargetMs : idleInterval);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
208
229
|
try {
|
|
209
230
|
const ref = rootRef.current;
|
|
210
231
|
if (ref) {
|
|
211
|
-
|
|
212
|
-
|
|
232
|
+
// Live frames are aggressively downscaled + low quality so capture+encode stays under ~50ms on mid devices.
|
|
233
|
+
const uri = await captureRef(ref as any, {
|
|
234
|
+
format: "jpg",
|
|
235
|
+
quality: liveMode ? 0.25 : 0.45,
|
|
236
|
+
result: "base64",
|
|
237
|
+
width: liveMode ? 360 : 540,
|
|
238
|
+
});
|
|
213
239
|
const blob = base64ToUint8Array(uri);
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
sendBinaryFrame(wsRef.current, blob, 0, 0);
|
|
240
|
+
|
|
241
|
+
if (liveMode) {
|
|
242
|
+
// ---- LIVE PATH: stream binary over WS only. NO REST upload (too slow). ----
|
|
243
|
+
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
|
244
|
+
sendBinaryFrame(wsRef.current, blob, 0, 0);
|
|
245
|
+
}
|
|
246
|
+
// Persist a sampled keyframe at most once every 10s for the replay timeline.
|
|
247
|
+
if (startedAt - lastUploadFrameAt > 10000) {
|
|
248
|
+
lastUploadFrameAt = startedAt;
|
|
249
|
+
uploadFrameAsync(serverUrl, sessionId, ingestToken, uri, blob).then((path) => {
|
|
250
|
+
if (path) queueEvent("screenshot", { w: 0, h: 0 }, path);
|
|
251
|
+
}).catch(() => {});
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
// ---- IDLE PATH: upload for replay. ----
|
|
255
|
+
const path = await uploadFrameAsync(serverUrl, sessionId, ingestToken, uri, blob);
|
|
256
|
+
queueEvent("screenshot", { w: 0, h: 0 }, path || undefined);
|
|
232
257
|
}
|
|
233
258
|
}
|
|
234
259
|
} catch {}
|
|
235
|
-
|
|
260
|
+
// Self-pacing: schedule next tick relative to how long this one took.
|
|
261
|
+
const elapsed = Date.now() - startedAt;
|
|
262
|
+
const next = liveMode ? Math.max(0, liveTargetMs - elapsed) : Math.max(0, idleInterval - elapsed);
|
|
263
|
+
setTimeout(tick, next);
|
|
236
264
|
};
|
|
237
|
-
setTimeout(tick,
|
|
265
|
+
setTimeout(tick, liveMode ? liveTargetMs : idleInterval);
|
|
238
266
|
return () => { stopped = true; };
|
|
239
267
|
}, [sessionId, ingestToken, liveMode, paused, keyboardVisible, config.disableScreenshots, serverUrl, queueEvent]);
|
|
240
268
|
|
|
@@ -428,6 +456,31 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
428
456
|
}
|
|
429
457
|
|
|
430
458
|
// ---- helpers ----
|
|
459
|
+
async function uploadFrameAsync(
|
|
460
|
+
serverUrl: string,
|
|
461
|
+
sessionId: string | null,
|
|
462
|
+
ingestToken: string | null,
|
|
463
|
+
base64: string,
|
|
464
|
+
blob: Uint8Array,
|
|
465
|
+
): Promise<string | null> {
|
|
466
|
+
if (!sessionId || !ingestToken) return null;
|
|
467
|
+
try {
|
|
468
|
+
const r = await fetch(`${serverUrl}/api/guided-support/storage/uploads/request-url`, {
|
|
469
|
+
method: "POST",
|
|
470
|
+
headers: { "Content-Type": "application/json", "x-ingest-token": ingestToken },
|
|
471
|
+
body: JSON.stringify({ sessionId, name: `frame-${Date.now()}.jpg`, size: blob.length, contentType: "image/jpeg" }),
|
|
472
|
+
});
|
|
473
|
+
const meta = await r.json();
|
|
474
|
+
const fd = new FormData();
|
|
475
|
+
// @ts-ignore - RN FormData accepts {uri,name,type}
|
|
476
|
+
fd.append("file", { uri: `data:image/jpeg;base64,${base64}`, name: "frame.jpg", type: "image/jpeg" });
|
|
477
|
+
const up = await fetch(`${serverUrl}${meta.uploadUrl}`, { method: "POST", body: fd as any });
|
|
478
|
+
return up.ok ? meta.path : null;
|
|
479
|
+
} catch {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
431
484
|
function base64ToUint8Array(b64: string): Uint8Array {
|
|
432
485
|
const bin = globalThis.atob ? globalThis.atob(b64) : Buffer.from(b64, "base64").toString("binary");
|
|
433
486
|
const out = new Uint8Array(bin.length);
|