@akhilpulse/samadhaan-session-replay 0.6.6 → 0.6.8
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 +114 -102
- package/src/voice.ts +18 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akhilpulse/samadhaan-session-replay",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.8",
|
|
4
4
|
"description": "React Native SDK for Samadhaan Guided Support — session recording, live remote screen-share, take-control, guided-tour overlays, and Shake-to-Assist AI voice guide.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"module": "src/index.ts",
|
|
@@ -56,7 +56,7 @@ let pixelCopyDisabled = false; // turned true after repeated runtime failures to
|
|
|
56
56
|
let pixelCopyFailStreak = 0; // consecutive non-busy failures; resets on success
|
|
57
57
|
|
|
58
58
|
const STORAGE_KEY = "@akhilpulse/samadhaan-session-replay/sessionId";
|
|
59
|
-
const SDK_VERSION = "0.6.
|
|
59
|
+
const SDK_VERSION = "0.6.8";
|
|
60
60
|
|
|
61
61
|
// ---- Module-level session singleton --------------------------------------
|
|
62
62
|
// Multiple SessionReplayProvider mounts (intentional or via React StrictMode's
|
|
@@ -162,7 +162,7 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
162
162
|
const [assistantPhase, setAssistantPhase] = useState<AssistantPhase>("idle");
|
|
163
163
|
const [assistMicStatus, setAssistMicStatus] = useState<MicStatus | null>(null);
|
|
164
164
|
const [assistantArrow, setAssistantArrow] = useState<
|
|
165
|
-
{ nx: number; ny: number; label?: string; instruction?: string } | null
|
|
165
|
+
{ nx: number; ny: number; nw?: number; nh?: number; label?: string; instruction?: string } | null
|
|
166
166
|
>(null);
|
|
167
167
|
const assistantSessionIdRef = useRef<string | null>(null);
|
|
168
168
|
const voiceRef = useRef<UltravoxVoiceSession | null>(null);
|
|
@@ -537,9 +537,15 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
537
537
|
setPointer(null);
|
|
538
538
|
break;
|
|
539
539
|
case "assistant_arrow": {
|
|
540
|
-
// AI voice guide
|
|
540
|
+
// AI voice guide highlighting the next element. Coords are normalized 0..1;
|
|
541
|
+
// nw/nh are the target's bounding-box size (optional, defaults applied in UI).
|
|
541
542
|
if (typeof msg.nx === "number" && typeof msg.ny === "number") {
|
|
542
|
-
setAssistantArrow({
|
|
543
|
+
setAssistantArrow({
|
|
544
|
+
nx: msg.nx, ny: msg.ny,
|
|
545
|
+
nw: typeof msg.nw === "number" ? msg.nw : undefined,
|
|
546
|
+
nh: typeof msg.nh === "number" ? msg.nh : undefined,
|
|
547
|
+
label: msg.label, instruction: msg.instruction,
|
|
548
|
+
});
|
|
543
549
|
}
|
|
544
550
|
break;
|
|
545
551
|
}
|
|
@@ -877,20 +883,15 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
877
883
|
</Animated.View>
|
|
878
884
|
)}
|
|
879
885
|
|
|
880
|
-
{/* Shake-to-Assist:
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
886
|
+
{/* Shake-to-Assist: iOS glass-style floating voice pill — frosted
|
|
887
|
+
translucent white pill with the multicolor voice wave + status
|
|
888
|
+
text, and a small ✕ to end the session. (Approximated glass:
|
|
889
|
+
translucency + hairline border + soft shadow; a real native blur
|
|
890
|
+
would require an extra native dependency.) */}
|
|
885
891
|
{assistantActive && (
|
|
886
892
|
<View style={styles.assistDock} pointerEvents="box-none">
|
|
887
|
-
<View style={
|
|
888
|
-
{
|
|
889
|
-
<View key={i} style={{ flex: 1, backgroundColor: "#ffffff", opacity: o }} />
|
|
890
|
-
))}
|
|
891
|
-
</View>
|
|
892
|
-
<View style={styles.assistStatusRow} pointerEvents="box-none">
|
|
893
|
-
{/* Wave + text are display-only: pass touches through to the app. */}
|
|
893
|
+
<View style={styles.assistGlassPill} pointerEvents="box-none">
|
|
894
|
+
{/* Wave + text are display-only: pass touches through. */}
|
|
894
895
|
<View pointerEvents="none">
|
|
895
896
|
<VoiceWave phase={assistantPhase} />
|
|
896
897
|
</View>
|
|
@@ -910,15 +911,17 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
910
911
|
</View>
|
|
911
912
|
)}
|
|
912
913
|
|
|
913
|
-
{/* Shake-to-Assist:
|
|
914
|
-
|
|
915
|
-
|
|
914
|
+
{/* Shake-to-Assist: glowing highlight border around the element to tap.
|
|
915
|
+
No text bubble — the voice says the instruction; the border shows
|
|
916
|
+
where. Rendered LAST so it draws above the voice pill. The overlay
|
|
917
|
+
stays mounted between steps so the border GLIDES from the previous
|
|
918
|
+
target to the next instead of popping. */}
|
|
916
919
|
{assistantActive && assistantArrow && (
|
|
917
|
-
<
|
|
920
|
+
<AssistHighlightOverlay
|
|
918
921
|
nx={assistantArrow.nx}
|
|
919
922
|
ny={assistantArrow.ny}
|
|
920
|
-
|
|
921
|
-
|
|
923
|
+
nw={assistantArrow.nw}
|
|
924
|
+
nh={assistantArrow.nh}
|
|
922
925
|
w={viewSizeRef.current.w}
|
|
923
926
|
h={viewSizeRef.current.h}
|
|
924
927
|
/>
|
|
@@ -930,75 +933,86 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
930
933
|
|
|
931
934
|
// ---- Shake-to-Assist UI bits ----
|
|
932
935
|
|
|
933
|
-
// Opacity stops for the Siri-style white gradient (top → bottom). Many thin
|
|
934
|
-
// strips fake a smooth linear gradient without a native gradient dependency.
|
|
935
|
-
const ASSIST_GRADIENT_STOPS = [
|
|
936
|
-
0, 0.03, 0.07, 0.13, 0.2, 0.29, 0.39, 0.5, 0.61, 0.71, 0.8, 0.87, 0.92, 0.95,
|
|
937
|
-
];
|
|
938
|
-
|
|
939
936
|
// Apple-ish multicolor voice wave bars.
|
|
940
937
|
const SIRI_WAVE_COLORS = ["#0A84FF", "#64D2FF", "#BF5AF2", "#FF375F", "#5E5CE6"];
|
|
941
938
|
|
|
942
939
|
/**
|
|
943
|
-
*
|
|
944
|
-
* -
|
|
945
|
-
* -
|
|
946
|
-
*
|
|
940
|
+
* Glowing rounded-rectangle highlight border around the element to tap.
|
|
941
|
+
* - No text: the voice speaks the instruction, the border shows where.
|
|
942
|
+
* - Position + size are spring-animated, so when the next step arrives the
|
|
943
|
+
* border GLIDES from the old target to the new one instead of popping.
|
|
944
|
+
* - On first appearance it fades in while settling down from a slightly
|
|
945
|
+
* larger scale (a soft "landing" effect).
|
|
946
|
+
* - The border glow pulses gently so the target stays easy to spot.
|
|
947
|
+
* - When the vision model doesn't return a box size (nw/nh), a sensible
|
|
948
|
+
* button-sized default is used around the center point.
|
|
947
949
|
*/
|
|
948
|
-
function
|
|
949
|
-
nx, ny,
|
|
950
|
-
}: { nx: number; ny: number;
|
|
951
|
-
const
|
|
950
|
+
function AssistHighlightOverlay({
|
|
951
|
+
nx, ny, nw, nh, w, h,
|
|
952
|
+
}: { nx: number; ny: number; nw?: number; nh?: number; w: number; h: number }) {
|
|
953
|
+
const W = w || 0;
|
|
954
|
+
const H = h || 0;
|
|
955
|
+
// Default box ~ a typical button when no size is provided; clamp to screen.
|
|
956
|
+
const boxW = Math.min(Math.max((nw || 0.42) * W, 56), Math.max(W - 16, 56));
|
|
957
|
+
const boxH = Math.min(Math.max((nh || 0.065) * H, 44), Math.max(H * 0.5, 44));
|
|
958
|
+
const left = Math.min(Math.max(nx * W - boxW / 2, 8), Math.max(W - boxW - 8, 8));
|
|
959
|
+
const top = Math.min(Math.max(ny * H - boxH / 2, 8), Math.max(H - boxH - 8, 8));
|
|
960
|
+
|
|
961
|
+
// Layout values animate with the JS driver (native driver can't animate
|
|
962
|
+
// left/top/width/height).
|
|
963
|
+
const anim = useRef({
|
|
964
|
+
left: new Animated.Value(left),
|
|
965
|
+
top: new Animated.Value(top),
|
|
966
|
+
width: new Animated.Value(boxW),
|
|
967
|
+
height: new Animated.Value(boxH),
|
|
968
|
+
appear: new Animated.Value(0),
|
|
969
|
+
pulse: new Animated.Value(0),
|
|
970
|
+
}).current;
|
|
971
|
+
|
|
952
972
|
useEffect(() => {
|
|
973
|
+
// Glide to the (new) target with a soft spring.
|
|
974
|
+
const springCfg = { useNativeDriver: false, friction: 9, tension: 50 };
|
|
975
|
+
Animated.parallel([
|
|
976
|
+
Animated.spring(anim.left, { toValue: left, ...springCfg }),
|
|
977
|
+
Animated.spring(anim.top, { toValue: top, ...springCfg }),
|
|
978
|
+
Animated.spring(anim.width, { toValue: boxW, ...springCfg }),
|
|
979
|
+
Animated.spring(anim.height, { toValue: boxH, ...springCfg }),
|
|
980
|
+
]).start();
|
|
981
|
+
}, [left, top, boxW, boxH, anim]);
|
|
982
|
+
|
|
983
|
+
useEffect(() => {
|
|
984
|
+
Animated.timing(anim.appear, { toValue: 1, duration: 320, useNativeDriver: false }).start();
|
|
953
985
|
const loop = Animated.loop(
|
|
954
986
|
Animated.sequence([
|
|
955
|
-
Animated.timing(pulse, { toValue: 1, duration:
|
|
956
|
-
Animated.timing(pulse, { toValue: 0, duration:
|
|
987
|
+
Animated.timing(anim.pulse, { toValue: 1, duration: 750, useNativeDriver: false }),
|
|
988
|
+
Animated.timing(anim.pulse, { toValue: 0, duration: 750, useNativeDriver: false }),
|
|
957
989
|
])
|
|
958
990
|
);
|
|
959
991
|
loop.start();
|
|
960
992
|
return () => loop.stop();
|
|
961
|
-
}, [
|
|
962
|
-
|
|
963
|
-
const
|
|
964
|
-
const
|
|
965
|
-
// Bubble placement:
|
|
966
|
-
// - target near the TOP edge -> bubble just below the target
|
|
967
|
-
// - target near the BOTTOM -> bubble pinned to the top of the screen, so it
|
|
968
|
-
// never covers the target or the app's bottom UI
|
|
969
|
-
// (bottom sheets / nav bars live there)
|
|
970
|
-
// - otherwise -> bubble just above the target
|
|
971
|
-
const nearTop = py < 96;
|
|
972
|
-
const nearBottom = py > (h || 0) - 260;
|
|
973
|
-
const bubblePos: any = nearTop
|
|
974
|
-
? { top: py + 36 }
|
|
975
|
-
: nearBottom
|
|
976
|
-
? { top: 70 }
|
|
977
|
-
: { bottom: (h || 0) - py + 36 };
|
|
978
|
-
const scale = pulse.interpolate({ inputRange: [0, 1], outputRange: [1, 1.45] });
|
|
979
|
-
const opacity = pulse.interpolate({ inputRange: [0, 1], outputRange: [1, 0.35] });
|
|
993
|
+
}, [anim]);
|
|
994
|
+
|
|
995
|
+
const glowOpacity = anim.pulse.interpolate({ inputRange: [0, 1], outputRange: [0.25, 0.75] });
|
|
996
|
+
const entryScale = anim.appear.interpolate({ inputRange: [0, 1], outputRange: [1.18, 1] });
|
|
980
997
|
|
|
981
998
|
return (
|
|
982
999
|
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
|
|
983
1000
|
<Animated.View
|
|
984
|
-
style={
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
<View style={
|
|
998
|
-
|
|
999
|
-
{!!instruction && <Text style={styles.assistBubbleText}>{instruction}</Text>}
|
|
1000
|
-
</View>
|
|
1001
|
-
)}
|
|
1001
|
+
style={{
|
|
1002
|
+
position: "absolute",
|
|
1003
|
+
left: anim.left,
|
|
1004
|
+
top: anim.top,
|
|
1005
|
+
width: anim.width,
|
|
1006
|
+
height: anim.height,
|
|
1007
|
+
opacity: anim.appear,
|
|
1008
|
+
transform: [{ scale: entryScale }],
|
|
1009
|
+
}}
|
|
1010
|
+
>
|
|
1011
|
+
{/* Outer soft pulsing glow ring */}
|
|
1012
|
+
<Animated.View style={[styles.assistGlowRing, { opacity: glowOpacity }]} />
|
|
1013
|
+
{/* Crisp inner border */}
|
|
1014
|
+
<View style={styles.assistHighlightBorder} />
|
|
1015
|
+
</Animated.View>
|
|
1002
1016
|
</View>
|
|
1003
1017
|
);
|
|
1004
1018
|
}
|
|
@@ -1170,37 +1184,35 @@ const styles = StyleSheet.create({
|
|
|
1170
1184
|
position: "absolute", width: 48, height: 48, borderRadius: 24,
|
|
1171
1185
|
backgroundColor: "rgba(239,68,68,0.25)",
|
|
1172
1186
|
},
|
|
1173
|
-
// ---- Shake-to-Assist
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1187
|
+
// ---- Shake-to-Assist glowing highlight border ----
|
|
1188
|
+
// Outer soft glow ring: slightly larger than the target box, thick
|
|
1189
|
+
// translucent border whose opacity pulses.
|
|
1190
|
+
assistGlowRing: {
|
|
1191
|
+
position: "absolute", left: -7, top: -7, right: -7, bottom: -7,
|
|
1192
|
+
borderRadius: 20, borderWidth: 6, borderColor: "rgba(59,130,246,0.45)",
|
|
1177
1193
|
},
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1194
|
+
// Crisp inner border hugging the target element.
|
|
1195
|
+
assistHighlightBorder: {
|
|
1196
|
+
position: "absolute", left: 0, top: 0, right: 0, bottom: 0,
|
|
1197
|
+
borderRadius: 14, borderWidth: 3, borderColor: "#3b82f6",
|
|
1198
|
+
shadowColor: "#3b82f6", shadowOpacity: 0.7, shadowRadius: 10,
|
|
1199
|
+
shadowOffset: { width: 0, height: 0 },
|
|
1182
1200
|
},
|
|
1183
|
-
//
|
|
1184
|
-
assistArrow: {
|
|
1185
|
-
position: "absolute", width: 18, height: 18,
|
|
1186
|
-
borderRightWidth: 4, borderBottomWidth: 4, borderColor: "#3b82f6",
|
|
1187
|
-
transform: [{ rotate: "45deg" }],
|
|
1188
|
-
},
|
|
1189
|
-
assistBubble: {
|
|
1190
|
-
position: "absolute", backgroundColor: "rgba(17,24,39,0.94)",
|
|
1191
|
-
borderRadius: 12, paddingVertical: 10, paddingHorizontal: 14,
|
|
1192
|
-
shadowColor: "#000", shadowOpacity: 0.3, shadowRadius: 10, shadowOffset: { width: 0, height: 4 },
|
|
1193
|
-
},
|
|
1194
|
-
assistBubbleLabel: { color: "#93c5fd", fontSize: 12, fontWeight: "700", marginBottom: 2 },
|
|
1195
|
-
assistBubbleText: { color: "#fff", fontSize: 14, lineHeight: 19 },
|
|
1196
|
-
// ---- Shake-to-Assist Siri-style bottom overlay ----
|
|
1201
|
+
// ---- Shake-to-Assist iOS glass-style floating voice pill ----
|
|
1197
1202
|
assistDock: {
|
|
1198
|
-
position: "absolute", left: 0, right: 0, bottom:
|
|
1199
|
-
|
|
1203
|
+
position: "absolute", left: 0, right: 0, bottom: 24,
|
|
1204
|
+
alignItems: "center", paddingHorizontal: 16,
|
|
1200
1205
|
},
|
|
1201
|
-
|
|
1202
|
-
flexDirection: "row", alignItems: "center",
|
|
1203
|
-
|
|
1206
|
+
assistGlassPill: {
|
|
1207
|
+
flexDirection: "row", alignItems: "center",
|
|
1208
|
+
maxWidth: "100%",
|
|
1209
|
+
backgroundColor: "rgba(255,255,255,0.78)",
|
|
1210
|
+
borderRadius: 999,
|
|
1211
|
+
borderWidth: 1, borderColor: "rgba(255,255,255,0.95)",
|
|
1212
|
+
paddingVertical: 12, paddingHorizontal: 18,
|
|
1213
|
+
shadowColor: "#000", shadowOpacity: 0.18, shadowRadius: 18,
|
|
1214
|
+
shadowOffset: { width: 0, height: 8 },
|
|
1215
|
+
elevation: 10,
|
|
1204
1216
|
},
|
|
1205
1217
|
assistStatusText: {
|
|
1206
1218
|
color: "#111827", fontSize: 15, fontWeight: "600",
|
package/src/voice.ts
CHANGED
|
@@ -23,11 +23,26 @@
|
|
|
23
23
|
|
|
24
24
|
import { NativeModules, PermissionsAndroid, Platform } from "react-native";
|
|
25
25
|
|
|
26
|
+
type OptionalModuleName = "react-native-shake" | "react-native-live-audio-stream";
|
|
27
|
+
|
|
26
28
|
// Loads an optional native module without crashing Metro when it's absent.
|
|
27
|
-
|
|
29
|
+
// NOTE: `require()` must be called with a static string literal — Metro's
|
|
30
|
+
// bundler cannot resolve `require(someVariable)` (it throws "Dynamic require
|
|
31
|
+
// ... not supported by Metro" at runtime, every time, regardless of whether
|
|
32
|
+
// the package is actually installed). A switch over literal require() calls
|
|
33
|
+
// keeps each one statically analyzable.
|
|
34
|
+
function loadOptional<T = any>(name: OptionalModuleName): T | null {
|
|
28
35
|
try {
|
|
29
|
-
|
|
30
|
-
|
|
36
|
+
switch (name) {
|
|
37
|
+
case "react-native-shake":
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
|
|
39
|
+
return require("react-native-shake") as T;
|
|
40
|
+
case "react-native-live-audio-stream":
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
|
|
42
|
+
return require("react-native-live-audio-stream") as T;
|
|
43
|
+
default:
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
31
46
|
} catch {
|
|
32
47
|
return null;
|
|
33
48
|
}
|