@akhilpulse/samadhaan-session-replay 0.2.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akhilpulse/samadhaan-session-replay",
3
- "version": "0.2.1",
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.1";
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";
@@ -213,47 +213,56 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
213
213
  useEffect(() => {
214
214
  if (config.disableScreenshots || !sessionId) return;
215
215
  let stopped = false;
216
- const interval = liveMode ? 200 : 10000;
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;
217
222
  const tick = async () => {
218
223
  if (stopped) return;
219
- if (paused || keyboardVisible) { setTimeout(tick, interval); return; }
224
+ const startedAt = Date.now();
225
+ if (paused || keyboardVisible) {
226
+ setTimeout(tick, liveMode ? liveTargetMs : idleInterval);
227
+ return;
228
+ }
220
229
  try {
221
230
  const ref = rootRef.current;
222
231
  if (ref) {
223
- // Cap capture width to keep payloads small. Dashboard viewport is 960px;
224
- // 540px wide JPEGs render fine and load 4-6× faster than full-resolution captures.
232
+ // Live frames are aggressively downscaled + low quality so capture+encode stays under ~50ms on mid devices.
225
233
  const uri = await captureRef(ref as any, {
226
234
  format: "jpg",
227
- quality: liveMode ? 0.3 : 0.45,
235
+ quality: liveMode ? 0.25 : 0.45,
228
236
  result: "base64",
229
- width: liveMode ? 480 : 540,
237
+ width: liveMode ? 360 : 540,
230
238
  });
231
- // upload via request-url then PUT
232
239
  const blob = base64ToUint8Array(uri);
233
- let path: string | null = null;
234
- try {
235
- const r = await fetch(`${serverUrl}/api/guided-support/storage/uploads/request-url`, {
236
- method: "POST",
237
- headers: { "Content-Type": "application/json", "x-ingest-token": ingestToken || "" },
238
- body: JSON.stringify({ sessionId, name: `frame-${Date.now()}.jpg`, size: blob.length, contentType: "image/jpeg" }),
239
- });
240
- const meta = await r.json();
241
- const fd = new FormData();
242
- // @ts-ignore
243
- fd.append("file", { uri: `data:image/jpeg;base64,${uri}`, name: "frame.jpg", type: "image/jpeg" });
244
- const up = await fetch(`${serverUrl}${meta.uploadUrl}`, { method: "POST", body: fd as any });
245
- if (up.ok) path = meta.path;
246
- } catch {}
247
- queueEvent("screenshot", { w: 0, h: 0 }, path || undefined);
248
- if (liveMode && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
249
- // also stream binary frame
250
- 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);
251
257
  }
252
258
  }
253
259
  } catch {}
254
- setTimeout(tick, interval);
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);
255
264
  };
256
- setTimeout(tick, interval);
265
+ setTimeout(tick, liveMode ? liveTargetMs : idleInterval);
257
266
  return () => { stopped = true; };
258
267
  }, [sessionId, ingestToken, liveMode, paused, keyboardVisible, config.disableScreenshots, serverUrl, queueEvent]);
259
268
 
@@ -447,6 +456,31 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
447
456
  }
448
457
 
449
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
+
450
484
  function base64ToUint8Array(b64: string): Uint8Array {
451
485
  const bin = globalThis.atob ? globalThis.atob(b64) : Buffer.from(b64, "base64").toString("binary");
452
486
  const out = new Uint8Array(bin.length);