@poolse/react-native-calls 0.1.3 → 0.3.0

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.
Files changed (30) hide show
  1. package/android/build.gradle +7 -0
  2. package/android/consumer-rules.pro +11 -0
  3. package/android/src/main/AndroidManifest.xml +2 -0
  4. package/android/src/main/java/dev/poolse/callcore/CallCoreRuntime.kt +245 -110
  5. package/android/src/main/java/dev/poolse/callcore/CallMachine.kt +113 -18
  6. package/android/src/main/java/dev/poolse/callcore/Ports.kt +17 -2
  7. package/android/src/main/java/dev/poolse/callcore/android/AndroidRingSurface.kt +51 -4
  8. package/android/src/main/java/dev/poolse/callcore/android/CallActionReceiver.kt +4 -3
  9. package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +269 -25
  10. package/android/src/main/java/dev/poolse/callcore/android/CallForegroundService.kt +228 -47
  11. package/android/src/main/java/dev/poolse/callcore/android/CallHttpClient.kt +97 -46
  12. package/android/src/main/java/dev/poolse/callcore/android/CallMessagingService.kt +59 -13
  13. package/android/src/main/java/dev/poolse/callcore/android/CallVideo.kt +36 -0
  14. package/android/src/main/java/dev/poolse/callcore/android/IncomingCallActivity.kt +100 -12
  15. package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +15 -0
  16. package/android/src/main/java/expo/modules/poolsecalls/PoolseCallVideoView.kt +64 -0
  17. package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +85 -17
  18. package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitMediaPort.kt +325 -23
  19. package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitVideoSurface.kt +107 -0
  20. package/android/src/test/java/dev/poolse/callcore/android/CallHttpClientTest.kt +61 -0
  21. package/dist/index.cjs +34 -0
  22. package/dist/index.cjs.map +1 -1
  23. package/dist/index.d.cts +52 -3
  24. package/dist/index.d.ts +52 -3
  25. package/dist/index.js +30 -2
  26. package/dist/index.js.map +1 -1
  27. package/package.json +2 -1
  28. package/src/expo-modules-core.d.ts +5 -0
  29. package/src/index.ts +91 -3
  30. package/src/react-shims.d.ts +22 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":["requireOptionalNativeModule"],"mappings":";;;;;AAqEA,IAAM,MAAA,GAASA,4CAA+C,aAAa,CAAA;AAGpE,IAAM,uBAAuB,MAAA,IAAU;AAG9C,eAAsB,SAAA,CAAU,YAAoB,OAAA,EAA2C;AAC7F,EAAA,MAAM,MAAA,EAAQ,SAAA,CAAU,UAAA,EAAY,OAAA,IAAW,IAAI,CAAA;AACrD;AAGO,SAAS,YAAA,GAAgC;AAC9C,EAAA,OAAO,MAAA,EAAQ,YAAA,EAAa,CAAE,QAAA,IAAY,IAAA;AAC5C;AAKO,SAAS,YAAY,EAAA,EAAsD;AAChF,EAAA,IAAI,CAAC,MAAA,EAAQ,OAAO,MAAM,MAAA;AAC1B,EAAA,MAAM,GAAA,GAAM,OAAO,WAAA,CAAY,aAAA,EAAe,CAAC,OAAA,KAAY,EAAA,CAAG,OAAA,CAAQ,QAAQ,CAAC,CAAA;AAC/E,EAAA,OAAO,MAAM,IAAI,MAAA,EAAO;AAC1B;AAEO,SAAS,KAAA,CAAM,gBAAwB,OAAA,EAAsC;AAClF,EAAA,OAAO,MAAA,EAAQ,MAAM,cAAA,EAAgB,OAAA,CAAQ,OAAO,OAAA,CAAQ,YAAY,CAAA,IAAK,OAAA,CAAQ,OAAA,EAAQ;AAC/F;AAEO,SAAS,IAAA,CACd,MAAA,EACA,cAAA,EACA,YAAA,EACe;AACf,EAAA,OAAO,QAAQ,IAAA,CAAK,MAAA,EAAQ,gBAAgB,YAAY,CAAA,IAAK,QAAQ,OAAA,EAAQ;AAC/E;AAEO,IAAM,MAAA,GAAS,MAAY,MAAA,EAAQ,MAAA;AACnC,IAAM,OAAA,GAAU,MAAY,MAAA,EAAQ,OAAA;AACpC,IAAM,MAAA,GAAS,MAAY,MAAA,EAAQ,MAAA;AACnC,IAAM,QAAA,GAAW,CAAC,KAAA,KAAyB,MAAA,EAAQ,SAAS,KAAK;AACjE,IAAM,SAAA,GAAY,CAAC,EAAA,KAAsB,MAAA,EAAQ,UAAU,EAAE;AAC7D,IAAM,YAAA,GAAe,MAAY,MAAA,EAAQ,YAAA","file":"index.cjs","sourcesContent":["// @poolse/react-native-calls — the React Native face of the native\n// call core (CONTRACT.md §8). A thin, typed surface over the native\n// module: commands in, one snapshot event out. All call logic lives in\n// the native cores; nothing here decides anything about a call.\n//\n// On a platform or build without the native module (Expo Go, web,\n// unlinked), every command is a safe no-op and the state stays idle —\n// so a host can render against this unconditionally.\nimport { requireOptionalNativeModule } from 'expo-modules-core';\n\n/** A call's complete state. `null` means idle — no call. */\nexport interface Snapshot {\n cv: 1;\n callId: string;\n conversationId: string;\n conversationName: string | null;\n direction: 'incoming' | 'outgoing' | 'joined';\n media: 'audio' | 'video';\n phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';\n reason?:\n | 'left'\n | 'finished'\n | 'declined_all'\n | 'busy_all'\n | 'no_answer'\n | 'cancelled'\n | 'connection_lost'\n | 'failed';\n participants: Array<{\n userId: string;\n externalId: string;\n speaking: boolean;\n muted: boolean;\n cameraOn: boolean;\n }>;\n audioRoute: 'earpiece' | 'speaker' | 'bluetooth' | 'headset';\n mutedSelf: boolean;\n cameraOn: boolean;\n}\n\nexport interface ConfigureOptions {\n /** Drawable resource NAME for the call notification's small icon. */\n smallIcon?: string;\n /** Raw resource NAME of the ringback tone (Android). */\n ringback?: string;\n}\n\nexport interface PlaceOptions {\n media: 'audio' | 'video';\n /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */\n sessionToken: string;\n}\n\ntype NativeSnapshot = { snapshot: Snapshot | null };\n\ninterface PoolseCallsNative {\n configure(apiBaseUrl: string, options: ConfigureOptions | null): Promise<void>;\n getCallState(): NativeSnapshot;\n place(conversationId: string, media: string, sessionToken: string): Promise<void>;\n join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;\n answer(): void;\n decline(): void;\n hangUp(): void;\n setMuted(muted: boolean): void;\n setCamera(on: boolean): void;\n switchCamera(): void;\n addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n}\n\nconst native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');\n\n/** True when the native call core is present in this build. */\nexport const nativeCallsAvailable = native != null;\n\n/** Configure once, before any call can arrive. No secrets — just the API origin. */\nexport async function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void> {\n await native?.configure(apiBaseUrl, options ?? null);\n}\n\n/** The current snapshot — safe to read at any moment, including cold answer. */\nexport function getCallState(): Snapshot | null {\n return native?.getCallState().snapshot ?? null;\n}\n\nexport type Unsubscribe = () => void;\n\n/** Subscribe to call state. Fires a full snapshot on every change. */\nexport function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe {\n if (!native) return () => undefined;\n const sub = native.addListener('onCallState', (payload) => cb(payload.snapshot));\n return () => sub.remove();\n}\n\nexport function place(conversationId: string, options: PlaceOptions): Promise<void> {\n return native?.place(conversationId, options.media, options.sessionToken) ?? Promise.resolve();\n}\n\nexport function join(\n callId: string,\n conversationId: string | null,\n sessionToken: string,\n): Promise<void> {\n return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();\n}\n\nexport const answer = (): void => native?.answer();\nexport const decline = (): void => native?.decline();\nexport const hangUp = (): void => native?.hangUp();\nexport const setMuted = (muted: boolean): void => native?.setMuted(muted);\nexport const setCamera = (on: boolean): void => native?.setCamera(on);\nexport const switchCamera = (): void => native?.switchCamera();\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":["requireOptionalNativeModule","requireNativeViewManager","createElement","View"],"mappings":";;;;;;;AAqFA,IAAM,MAAA,GAASA,4CAA+C,aAAa,CAAA;AAGpE,IAAM,uBAAuB,MAAA,IAAU;AAG9C,eAAsB,SAAA,CAAU,YAAoB,OAAA,EAA2C;AAC7F,EAAA,MAAM,MAAA,EAAQ,SAAA,CAAU,UAAA,EAAY,OAAA,IAAW,IAAI,CAAA;AACrD;AAGO,SAAS,YAAA,GAAgC;AAC9C,EAAA,OAAO,MAAA,EAAQ,YAAA,EAAa,CAAE,QAAA,IAAY,IAAA;AAC5C;AAKO,SAAS,YAAY,EAAA,EAAsD;AAChF,EAAA,IAAI,CAAC,MAAA,EAAQ,OAAO,MAAM,MAAA;AAC1B,EAAA,MAAM,GAAA,GAAM,OAAO,WAAA,CAAY,aAAA,EAAe,CAAC,OAAA,KAAY,EAAA,CAAG,OAAA,CAAQ,QAAQ,CAAC,CAAA;AAC/E,EAAA,OAAO,MAAM,IAAI,MAAA,EAAO;AAC1B;AAEO,SAAS,KAAA,CAAM,gBAAwB,OAAA,EAAsC;AAClF,EAAA,OAAO,MAAA,EAAQ,MAAM,cAAA,EAAgB,OAAA,CAAQ,OAAO,OAAA,CAAQ,YAAY,CAAA,IAAK,OAAA,CAAQ,OAAA,EAAQ;AAC/F;AAEO,SAAS,IAAA,CACd,MAAA,EACA,cAAA,EACA,YAAA,EACe;AACf,EAAA,OAAO,QAAQ,IAAA,CAAK,MAAA,EAAQ,gBAAgB,YAAY,CAAA,IAAK,QAAQ,OAAA,EAAQ;AAC/E;AAEO,IAAM,MAAA,GAAS,MAAY,MAAA,EAAQ,MAAA;AACnC,IAAM,OAAA,GAAU,MAAY,MAAA,EAAQ,OAAA;AACpC,IAAM,MAAA,GAAS,MAAY,MAAA,EAAQ,MAAA;AACnC,IAAM,QAAA,GAAW,CAAC,KAAA,KAAyB,MAAA,EAAQ,SAAS,KAAK;AACjE,IAAM,SAAA,GAAY,CAAC,EAAA,KAAsB,MAAA,EAAQ,UAAU,EAAE;AAC7D,IAAM,YAAA,GAAe,MAAY,MAAA,EAAQ,YAAA;AAEzC,IAAM,UAAA,GAAa,CAAC,EAAA,KAAsB,MAAA,EAAQ,WAAW,EAAE;AAM/D,IAAM,cAAc,CAAC,MAAA,EAAgB,WAC1C,MAAA,EAAQ,WAAA,CAAY,QAAQ,MAAM;AAQ7B,IAAM,cAAA,GAAiB,CAAC,MAAA,EAAgB,MAAA,KAC7C,QAAQ,cAAA,CAAe,MAAA,EAAQ,UAAU,IAAI;AAMxC,IAAM,sBAAA,GAAyB,MAAe,MAAA,EAAQ,sBAAA,EAAuB,IAAK;AAGlF,IAAM,4BAAA,GAA+B,MAAY,MAAA,EAAQ,4BAAA;AAgBhE,IAAI,WAAA;AAGJ,SAAS,eAAA,GAA0D;AACjE,EAAA,IAAI,WAAA,KAAgB,QAAW,OAAO,WAAA;AACtC,EAAA,WAAA,GAAc,IAAA;AACd,EAAA,IAAI,MAAA,EAAQ;AACV,IAAA,IAAI;AACF,MAAA,WAAA,GAAcC,wCAAA,CAA2C,eAAe,iBAAiB,CAAA;AAAA,IAC3F,CAAA,CAAA,MAAQ;AACN,MAAA,WAAA,GAAc,IAAA;AAAA,IAChB;AAAA,EACF;AACA,EAAA,OAAO,WAAA;AACT;AAQO,SAAS,cAAc,KAAA,EAAgD;AAC5E,EAAA,MAAM,SAAS,eAAA,EAAgB;AAC/B,EAAA,IAAI,CAAC,MAAA,EAAQ;AACX,IAAA,MAAM,EAAE,aAAA,EAAe,EAAA,EAAI,KAAA,EAAO,EAAA,EAAI,MAAA,EAAQ,EAAA,EAAI,GAAA,EAAK,EAAA,EAAI,GAAG,IAAA,EAAK,GAAI,KAAA;AACvE,IAAA,OAAOC,mBAAA,CAAcC,kBAAM,IAAI,CAAA;AAAA,EACjC;AACA,EAAA,OAAOD,mBAAA,CAAc,QAAQ,KAAK,CAAA;AACpC","file":"index.cjs","sourcesContent":["// @poolse/react-native-calls — the React Native face of the native\n// call core (CONTRACT.md §8). A thin, typed surface over the native\n// module: commands in, one snapshot event out. All call logic lives in\n// the native cores; nothing here decides anything about a call.\n//\n// On a platform or build without the native module (Expo Go, web,\n// unlinked), every command is a safe no-op and the state stays idle —\n// so a host can render against this unconditionally.\nimport { createElement, type ComponentType, type ReactElement } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { requireNativeViewManager, requireOptionalNativeModule } from 'expo-modules-core';\n\n/**\n * A call's complete state. `null` means idle — no call. After a call\n * ends the snapshot stays `phase: 'ended'` (with `reason`) for ~2.5 s,\n * then becomes `null`.\n */\nexport interface Snapshot {\n cv: 1;\n callId: string;\n conversationId: string;\n conversationName: string | null;\n direction: 'incoming' | 'outgoing' | 'joined';\n media: 'audio' | 'video';\n phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';\n reason?:\n | 'left'\n | 'finished'\n | 'declined_all'\n | 'busy_all'\n | 'no_answer'\n | 'cancelled'\n | 'connection_lost'\n | 'failed';\n /** REMOTE participants only; `userId` is the LiveKit identity. */\n participants: Array<{\n userId: string;\n externalId: string;\n speaking: boolean;\n muted: boolean;\n /** True only with a published, subscribed, unmuted camera track (show video, not avatar). */\n cameraOn: boolean;\n }>;\n audioRoute: 'earpiece' | 'speaker' | 'bluetooth' | 'headset';\n mutedSelf: boolean;\n /** True only while the local camera track is actually published and enabled. */\n cameraOn: boolean;\n /** Facing of the published local camera; absent when the camera is off. */\n cameraFacing?: 'front' | 'back';\n}\n\nexport interface ConfigureOptions {\n /** Drawable resource NAME for the call notification's small icon. */\n smallIcon?: string;\n /** Raw resource NAME of the ringback tone (Android). */\n ringback?: string;\n}\n\nexport interface PlaceOptions {\n media: 'audio' | 'video';\n /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */\n sessionToken: string;\n}\n\ntype NativeSnapshot = { snapshot: Snapshot | null };\n\ninterface PoolseCallsNative {\n configure(apiBaseUrl: string, options: ConfigureOptions | null): Promise<void>;\n getCallState(): NativeSnapshot;\n place(conversationId: string, media: string, sessionToken: string): Promise<void>;\n join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;\n answer(): void;\n decline(): void;\n hangUp(): void;\n setMuted(muted: boolean): void;\n setCamera(on: boolean): void;\n switchCamera(): void;\n setSpeaker(on: boolean): void;\n onRingEnded(callId: string, reason: string): void;\n onCallFinished(callId: string, reason: string | null): void;\n canUseFullScreenIntent(): boolean;\n openFullScreenIntentSettings(): void;\n addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n}\n\nconst native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');\n\n/** True when the native call core is present in this build. */\nexport const nativeCallsAvailable = native != null;\n\n/** Configure once, before any call can arrive. No secrets — just the API origin. */\nexport async function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void> {\n await native?.configure(apiBaseUrl, options ?? null);\n}\n\n/** The current snapshot — safe to read at any moment, including cold answer and before configure. */\nexport function getCallState(): Snapshot | null {\n return native?.getCallState().snapshot ?? null;\n}\n\nexport type Unsubscribe = () => void;\n\n/** Subscribe to call state. Fires a full snapshot on every change. */\nexport function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe {\n if (!native) return () => undefined;\n const sub = native.addListener('onCallState', (payload) => cb(payload.snapshot));\n return () => sub.remove();\n}\n\nexport function place(conversationId: string, options: PlaceOptions): Promise<void> {\n return native?.place(conversationId, options.media, options.sessionToken) ?? Promise.resolve();\n}\n\nexport function join(\n callId: string,\n conversationId: string | null,\n sessionToken: string,\n): Promise<void> {\n return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();\n}\n\nexport const answer = (): void => native?.answer();\nexport const decline = (): void => native?.decline();\nexport const hangUp = (): void => native?.hangUp();\nexport const setMuted = (muted: boolean): void => native?.setMuted(muted);\nexport const setCamera = (on: boolean): void => native?.setCamera(on);\nexport const switchCamera = (): void => native?.switchCamera();\n/** Route call audio to the loudspeaker (`true`) or back to earpiece / headset (`false`). */\nexport const setSpeaker = (on: boolean): void => native?.setSpeaker(on);\n\n/**\n * Socket forwarding for an open app: a `call:ring_ended` seen on the\n * socket. Idempotent with the push path — the core dedupes.\n */\nexport const onRingEnded = (callId: string, reason: string): void =>\n native?.onRingEnded(callId, reason);\n\n/**\n * Socket forwarding: `call:finished`. Pass the payload's `reason`\n * (`finished` | `declined_all` | `no_answer` | `cancelled`) so an\n * unanswered caller sees \"Declined\"/\"No answer\", not \"Call ended\".\n * Idempotent with the `finished` push.\n */\nexport const onCallFinished = (callId: string, reason?: string): void =>\n native?.onCallFinished(callId, reason ?? null);\n\n/**\n * Android 14+: whether the app may show the full-screen ring over the\n * lock screen. `true` on older Android and where the module is absent.\n */\nexport const canUseFullScreenIntent = (): boolean => native?.canUseFullScreenIntent() ?? true;\n\n/** Open the system screen where the user grants full-screen intents (falls back to notification settings). */\nexport const openFullScreenIntentSettings = (): void => native?.openFullScreenIntentSettings();\n\n// ── video ─────────────────────────────────────────────────────────────\n\nexport interface CallVideoViewProps extends ViewProps {\n /** Remote participant's userId (LiveKit identity). Omit with local=true for self view. */\n participantId?: string;\n local?: boolean;\n /** default: true for local front camera, false otherwise */\n mirror?: boolean;\n /** default 'cover' */\n fit?: 'cover' | 'contain';\n}\n\ntype NativeVideoProps = CallVideoViewProps;\n\nlet nativeVideo: ComponentType<NativeVideoProps> | null | undefined;\n\n/** Resolved lazily (first render) and only when the native module exists. */\nfunction nativeVideoView(): ComponentType<NativeVideoProps> | null {\n if (nativeVideo !== undefined) return nativeVideo;\n nativeVideo = null;\n if (native) {\n try {\n nativeVideo = requireNativeViewManager<NativeVideoProps>('PoolseCalls', 'PoolseCallVideo');\n } catch {\n nativeVideo = null; // an older native build without the view\n }\n }\n return nativeVideo;\n}\n\n/**\n * A participant's camera (or, with `local`, your own), rendered natively\n * by the call core. Shows nothing (stays transparent) while that camera\n * is off — draw the avatar from the snapshot's `cameraOn` underneath.\n * Without the native module it renders an empty View.\n */\nexport function CallVideoView(props: CallVideoViewProps): ReactElement | null {\n const Native = nativeVideoView();\n if (!Native) {\n const { participantId: _p, local: _l, mirror: _m, fit: _f, ...rest } = props;\n return createElement(View, rest);\n }\n return createElement(Native, props);\n}\n"]}
package/dist/index.d.cts CHANGED
@@ -1,4 +1,11 @@
1
- /** A call's complete state. `null` means idle — no call. */
1
+ import { ReactElement } from 'react';
2
+ import { ViewProps } from 'react-native';
3
+
4
+ /**
5
+ * A call's complete state. `null` means idle — no call. After a call
6
+ * ends the snapshot stays `phase: 'ended'` (with `reason`) for ~2.5 s,
7
+ * then becomes `null`.
8
+ */
2
9
  interface Snapshot {
3
10
  cv: 1;
4
11
  callId: string;
@@ -8,16 +15,21 @@ interface Snapshot {
8
15
  media: 'audio' | 'video';
9
16
  phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';
10
17
  reason?: 'left' | 'finished' | 'declined_all' | 'busy_all' | 'no_answer' | 'cancelled' | 'connection_lost' | 'failed';
18
+ /** REMOTE participants only; `userId` is the LiveKit identity. */
11
19
  participants: Array<{
12
20
  userId: string;
13
21
  externalId: string;
14
22
  speaking: boolean;
15
23
  muted: boolean;
24
+ /** True only with a published, subscribed, unmuted camera track (show video, not avatar). */
16
25
  cameraOn: boolean;
17
26
  }>;
18
27
  audioRoute: 'earpiece' | 'speaker' | 'bluetooth' | 'headset';
19
28
  mutedSelf: boolean;
29
+ /** True only while the local camera track is actually published and enabled. */
20
30
  cameraOn: boolean;
31
+ /** Facing of the published local camera; absent when the camera is off. */
32
+ cameraFacing?: 'front' | 'back';
21
33
  }
22
34
  interface ConfigureOptions {
23
35
  /** Drawable resource NAME for the call notification's small icon. */
@@ -34,7 +46,7 @@ interface PlaceOptions {
34
46
  declare const nativeCallsAvailable: boolean;
35
47
  /** Configure once, before any call can arrive. No secrets — just the API origin. */
36
48
  declare function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void>;
37
- /** The current snapshot — safe to read at any moment, including cold answer. */
49
+ /** The current snapshot — safe to read at any moment, including cold answer and before configure. */
38
50
  declare function getCallState(): Snapshot | null;
39
51
  type Unsubscribe = () => void;
40
52
  /** Subscribe to call state. Fires a full snapshot on every change. */
@@ -47,5 +59,42 @@ declare const hangUp: () => void;
47
59
  declare const setMuted: (muted: boolean) => void;
48
60
  declare const setCamera: (on: boolean) => void;
49
61
  declare const switchCamera: () => void;
62
+ /** Route call audio to the loudspeaker (`true`) or back to earpiece / headset (`false`). */
63
+ declare const setSpeaker: (on: boolean) => void;
64
+ /**
65
+ * Socket forwarding for an open app: a `call:ring_ended` seen on the
66
+ * socket. Idempotent with the push path — the core dedupes.
67
+ */
68
+ declare const onRingEnded: (callId: string, reason: string) => void;
69
+ /**
70
+ * Socket forwarding: `call:finished`. Pass the payload's `reason`
71
+ * (`finished` | `declined_all` | `no_answer` | `cancelled`) so an
72
+ * unanswered caller sees "Declined"/"No answer", not "Call ended".
73
+ * Idempotent with the `finished` push.
74
+ */
75
+ declare const onCallFinished: (callId: string, reason?: string) => void;
76
+ /**
77
+ * Android 14+: whether the app may show the full-screen ring over the
78
+ * lock screen. `true` on older Android and where the module is absent.
79
+ */
80
+ declare const canUseFullScreenIntent: () => boolean;
81
+ /** Open the system screen where the user grants full-screen intents (falls back to notification settings). */
82
+ declare const openFullScreenIntentSettings: () => void;
83
+ interface CallVideoViewProps extends ViewProps {
84
+ /** Remote participant's userId (LiveKit identity). Omit with local=true for self view. */
85
+ participantId?: string;
86
+ local?: boolean;
87
+ /** default: true for local front camera, false otherwise */
88
+ mirror?: boolean;
89
+ /** default 'cover' */
90
+ fit?: 'cover' | 'contain';
91
+ }
92
+ /**
93
+ * A participant's camera (or, with `local`, your own), rendered natively
94
+ * by the call core. Shows nothing (stays transparent) while that camera
95
+ * is off — draw the avatar from the snapshot's `cameraOn` underneath.
96
+ * Without the native module it renders an empty View.
97
+ */
98
+ declare function CallVideoView(props: CallVideoViewProps): ReactElement | null;
50
99
 
51
- export { type ConfigureOptions, type PlaceOptions, type Snapshot, type Unsubscribe, answer, configure, decline, getCallState, hangUp, join, nativeCallsAvailable, onCallState, place, setCamera, setMuted, switchCamera };
100
+ export { CallVideoView, type CallVideoViewProps, type ConfigureOptions, type PlaceOptions, type Snapshot, type Unsubscribe, answer, canUseFullScreenIntent, configure, decline, getCallState, hangUp, join, nativeCallsAvailable, onCallFinished, onCallState, onRingEnded, openFullScreenIntentSettings, place, setCamera, setMuted, setSpeaker, switchCamera };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,11 @@
1
- /** A call's complete state. `null` means idle — no call. */
1
+ import { ReactElement } from 'react';
2
+ import { ViewProps } from 'react-native';
3
+
4
+ /**
5
+ * A call's complete state. `null` means idle — no call. After a call
6
+ * ends the snapshot stays `phase: 'ended'` (with `reason`) for ~2.5 s,
7
+ * then becomes `null`.
8
+ */
2
9
  interface Snapshot {
3
10
  cv: 1;
4
11
  callId: string;
@@ -8,16 +15,21 @@ interface Snapshot {
8
15
  media: 'audio' | 'video';
9
16
  phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';
10
17
  reason?: 'left' | 'finished' | 'declined_all' | 'busy_all' | 'no_answer' | 'cancelled' | 'connection_lost' | 'failed';
18
+ /** REMOTE participants only; `userId` is the LiveKit identity. */
11
19
  participants: Array<{
12
20
  userId: string;
13
21
  externalId: string;
14
22
  speaking: boolean;
15
23
  muted: boolean;
24
+ /** True only with a published, subscribed, unmuted camera track (show video, not avatar). */
16
25
  cameraOn: boolean;
17
26
  }>;
18
27
  audioRoute: 'earpiece' | 'speaker' | 'bluetooth' | 'headset';
19
28
  mutedSelf: boolean;
29
+ /** True only while the local camera track is actually published and enabled. */
20
30
  cameraOn: boolean;
31
+ /** Facing of the published local camera; absent when the camera is off. */
32
+ cameraFacing?: 'front' | 'back';
21
33
  }
22
34
  interface ConfigureOptions {
23
35
  /** Drawable resource NAME for the call notification's small icon. */
@@ -34,7 +46,7 @@ interface PlaceOptions {
34
46
  declare const nativeCallsAvailable: boolean;
35
47
  /** Configure once, before any call can arrive. No secrets — just the API origin. */
36
48
  declare function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void>;
37
- /** The current snapshot — safe to read at any moment, including cold answer. */
49
+ /** The current snapshot — safe to read at any moment, including cold answer and before configure. */
38
50
  declare function getCallState(): Snapshot | null;
39
51
  type Unsubscribe = () => void;
40
52
  /** Subscribe to call state. Fires a full snapshot on every change. */
@@ -47,5 +59,42 @@ declare const hangUp: () => void;
47
59
  declare const setMuted: (muted: boolean) => void;
48
60
  declare const setCamera: (on: boolean) => void;
49
61
  declare const switchCamera: () => void;
62
+ /** Route call audio to the loudspeaker (`true`) or back to earpiece / headset (`false`). */
63
+ declare const setSpeaker: (on: boolean) => void;
64
+ /**
65
+ * Socket forwarding for an open app: a `call:ring_ended` seen on the
66
+ * socket. Idempotent with the push path — the core dedupes.
67
+ */
68
+ declare const onRingEnded: (callId: string, reason: string) => void;
69
+ /**
70
+ * Socket forwarding: `call:finished`. Pass the payload's `reason`
71
+ * (`finished` | `declined_all` | `no_answer` | `cancelled`) so an
72
+ * unanswered caller sees "Declined"/"No answer", not "Call ended".
73
+ * Idempotent with the `finished` push.
74
+ */
75
+ declare const onCallFinished: (callId: string, reason?: string) => void;
76
+ /**
77
+ * Android 14+: whether the app may show the full-screen ring over the
78
+ * lock screen. `true` on older Android and where the module is absent.
79
+ */
80
+ declare const canUseFullScreenIntent: () => boolean;
81
+ /** Open the system screen where the user grants full-screen intents (falls back to notification settings). */
82
+ declare const openFullScreenIntentSettings: () => void;
83
+ interface CallVideoViewProps extends ViewProps {
84
+ /** Remote participant's userId (LiveKit identity). Omit with local=true for self view. */
85
+ participantId?: string;
86
+ local?: boolean;
87
+ /** default: true for local front camera, false otherwise */
88
+ mirror?: boolean;
89
+ /** default 'cover' */
90
+ fit?: 'cover' | 'contain';
91
+ }
92
+ /**
93
+ * A participant's camera (or, with `local`, your own), rendered natively
94
+ * by the call core. Shows nothing (stays transparent) while that camera
95
+ * is off — draw the avatar from the snapshot's `cameraOn` underneath.
96
+ * Without the native module it renders an empty View.
97
+ */
98
+ declare function CallVideoView(props: CallVideoViewProps): ReactElement | null;
50
99
 
51
- export { type ConfigureOptions, type PlaceOptions, type Snapshot, type Unsubscribe, answer, configure, decline, getCallState, hangUp, join, nativeCallsAvailable, onCallState, place, setCamera, setMuted, switchCamera };
100
+ export { CallVideoView, type CallVideoViewProps, type ConfigureOptions, type PlaceOptions, type Snapshot, type Unsubscribe, answer, canUseFullScreenIntent, configure, decline, getCallState, hangUp, join, nativeCallsAvailable, onCallFinished, onCallState, onRingEnded, openFullScreenIntentSettings, place, setCamera, setMuted, setSpeaker, switchCamera };
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
1
- import { requireOptionalNativeModule } from 'expo-modules-core';
1
+ import { createElement } from 'react';
2
+ import { View } from 'react-native';
3
+ import { requireOptionalNativeModule, requireNativeViewManager } from 'expo-modules-core';
2
4
 
3
5
  // src/index.ts
4
6
  var native = requireOptionalNativeModule("PoolseCalls");
@@ -26,7 +28,33 @@ var hangUp = () => native?.hangUp();
26
28
  var setMuted = (muted) => native?.setMuted(muted);
27
29
  var setCamera = (on) => native?.setCamera(on);
28
30
  var switchCamera = () => native?.switchCamera();
31
+ var setSpeaker = (on) => native?.setSpeaker(on);
32
+ var onRingEnded = (callId, reason) => native?.onRingEnded(callId, reason);
33
+ var onCallFinished = (callId, reason) => native?.onCallFinished(callId, reason ?? null);
34
+ var canUseFullScreenIntent = () => native?.canUseFullScreenIntent() ?? true;
35
+ var openFullScreenIntentSettings = () => native?.openFullScreenIntentSettings();
36
+ var nativeVideo;
37
+ function nativeVideoView() {
38
+ if (nativeVideo !== void 0) return nativeVideo;
39
+ nativeVideo = null;
40
+ if (native) {
41
+ try {
42
+ nativeVideo = requireNativeViewManager("PoolseCalls", "PoolseCallVideo");
43
+ } catch {
44
+ nativeVideo = null;
45
+ }
46
+ }
47
+ return nativeVideo;
48
+ }
49
+ function CallVideoView(props) {
50
+ const Native = nativeVideoView();
51
+ if (!Native) {
52
+ const { participantId: _p, local: _l, mirror: _m, fit: _f, ...rest } = props;
53
+ return createElement(View, rest);
54
+ }
55
+ return createElement(Native, props);
56
+ }
29
57
 
30
- export { answer, configure, decline, getCallState, hangUp, join, nativeCallsAvailable, onCallState, place, setCamera, setMuted, switchCamera };
58
+ export { CallVideoView, answer, canUseFullScreenIntent, configure, decline, getCallState, hangUp, join, nativeCallsAvailable, onCallFinished, onCallState, onRingEnded, openFullScreenIntentSettings, place, setCamera, setMuted, setSpeaker, switchCamera };
31
59
  //# sourceMappingURL=index.js.map
32
60
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";;;AAqEA,IAAM,MAAA,GAAS,4BAA+C,aAAa,CAAA;AAGpE,IAAM,uBAAuB,MAAA,IAAU;AAG9C,eAAsB,SAAA,CAAU,YAAoB,OAAA,EAA2C;AAC7F,EAAA,MAAM,MAAA,EAAQ,SAAA,CAAU,UAAA,EAAY,OAAA,IAAW,IAAI,CAAA;AACrD;AAGO,SAAS,YAAA,GAAgC;AAC9C,EAAA,OAAO,MAAA,EAAQ,YAAA,EAAa,CAAE,QAAA,IAAY,IAAA;AAC5C;AAKO,SAAS,YAAY,EAAA,EAAsD;AAChF,EAAA,IAAI,CAAC,MAAA,EAAQ,OAAO,MAAM,MAAA;AAC1B,EAAA,MAAM,GAAA,GAAM,OAAO,WAAA,CAAY,aAAA,EAAe,CAAC,OAAA,KAAY,EAAA,CAAG,OAAA,CAAQ,QAAQ,CAAC,CAAA;AAC/E,EAAA,OAAO,MAAM,IAAI,MAAA,EAAO;AAC1B;AAEO,SAAS,KAAA,CAAM,gBAAwB,OAAA,EAAsC;AAClF,EAAA,OAAO,MAAA,EAAQ,MAAM,cAAA,EAAgB,OAAA,CAAQ,OAAO,OAAA,CAAQ,YAAY,CAAA,IAAK,OAAA,CAAQ,OAAA,EAAQ;AAC/F;AAEO,SAAS,IAAA,CACd,MAAA,EACA,cAAA,EACA,YAAA,EACe;AACf,EAAA,OAAO,QAAQ,IAAA,CAAK,MAAA,EAAQ,gBAAgB,YAAY,CAAA,IAAK,QAAQ,OAAA,EAAQ;AAC/E;AAEO,IAAM,MAAA,GAAS,MAAY,MAAA,EAAQ,MAAA;AACnC,IAAM,OAAA,GAAU,MAAY,MAAA,EAAQ,OAAA;AACpC,IAAM,MAAA,GAAS,MAAY,MAAA,EAAQ,MAAA;AACnC,IAAM,QAAA,GAAW,CAAC,KAAA,KAAyB,MAAA,EAAQ,SAAS,KAAK;AACjE,IAAM,SAAA,GAAY,CAAC,EAAA,KAAsB,MAAA,EAAQ,UAAU,EAAE;AAC7D,IAAM,YAAA,GAAe,MAAY,MAAA,EAAQ,YAAA","file":"index.js","sourcesContent":["// @poolse/react-native-calls — the React Native face of the native\n// call core (CONTRACT.md §8). A thin, typed surface over the native\n// module: commands in, one snapshot event out. All call logic lives in\n// the native cores; nothing here decides anything about a call.\n//\n// On a platform or build without the native module (Expo Go, web,\n// unlinked), every command is a safe no-op and the state stays idle —\n// so a host can render against this unconditionally.\nimport { requireOptionalNativeModule } from 'expo-modules-core';\n\n/** A call's complete state. `null` means idle — no call. */\nexport interface Snapshot {\n cv: 1;\n callId: string;\n conversationId: string;\n conversationName: string | null;\n direction: 'incoming' | 'outgoing' | 'joined';\n media: 'audio' | 'video';\n phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';\n reason?:\n | 'left'\n | 'finished'\n | 'declined_all'\n | 'busy_all'\n | 'no_answer'\n | 'cancelled'\n | 'connection_lost'\n | 'failed';\n participants: Array<{\n userId: string;\n externalId: string;\n speaking: boolean;\n muted: boolean;\n cameraOn: boolean;\n }>;\n audioRoute: 'earpiece' | 'speaker' | 'bluetooth' | 'headset';\n mutedSelf: boolean;\n cameraOn: boolean;\n}\n\nexport interface ConfigureOptions {\n /** Drawable resource NAME for the call notification's small icon. */\n smallIcon?: string;\n /** Raw resource NAME of the ringback tone (Android). */\n ringback?: string;\n}\n\nexport interface PlaceOptions {\n media: 'audio' | 'video';\n /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */\n sessionToken: string;\n}\n\ntype NativeSnapshot = { snapshot: Snapshot | null };\n\ninterface PoolseCallsNative {\n configure(apiBaseUrl: string, options: ConfigureOptions | null): Promise<void>;\n getCallState(): NativeSnapshot;\n place(conversationId: string, media: string, sessionToken: string): Promise<void>;\n join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;\n answer(): void;\n decline(): void;\n hangUp(): void;\n setMuted(muted: boolean): void;\n setCamera(on: boolean): void;\n switchCamera(): void;\n addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n}\n\nconst native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');\n\n/** True when the native call core is present in this build. */\nexport const nativeCallsAvailable = native != null;\n\n/** Configure once, before any call can arrive. No secrets — just the API origin. */\nexport async function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void> {\n await native?.configure(apiBaseUrl, options ?? null);\n}\n\n/** The current snapshot — safe to read at any moment, including cold answer. */\nexport function getCallState(): Snapshot | null {\n return native?.getCallState().snapshot ?? null;\n}\n\nexport type Unsubscribe = () => void;\n\n/** Subscribe to call state. Fires a full snapshot on every change. */\nexport function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe {\n if (!native) return () => undefined;\n const sub = native.addListener('onCallState', (payload) => cb(payload.snapshot));\n return () => sub.remove();\n}\n\nexport function place(conversationId: string, options: PlaceOptions): Promise<void> {\n return native?.place(conversationId, options.media, options.sessionToken) ?? Promise.resolve();\n}\n\nexport function join(\n callId: string,\n conversationId: string | null,\n sessionToken: string,\n): Promise<void> {\n return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();\n}\n\nexport const answer = (): void => native?.answer();\nexport const decline = (): void => native?.decline();\nexport const hangUp = (): void => native?.hangUp();\nexport const setMuted = (muted: boolean): void => native?.setMuted(muted);\nexport const setCamera = (on: boolean): void => native?.setCamera(on);\nexport const switchCamera = (): void => native?.switchCamera();\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";;;;;AAqFA,IAAM,MAAA,GAAS,4BAA+C,aAAa,CAAA;AAGpE,IAAM,uBAAuB,MAAA,IAAU;AAG9C,eAAsB,SAAA,CAAU,YAAoB,OAAA,EAA2C;AAC7F,EAAA,MAAM,MAAA,EAAQ,SAAA,CAAU,UAAA,EAAY,OAAA,IAAW,IAAI,CAAA;AACrD;AAGO,SAAS,YAAA,GAAgC;AAC9C,EAAA,OAAO,MAAA,EAAQ,YAAA,EAAa,CAAE,QAAA,IAAY,IAAA;AAC5C;AAKO,SAAS,YAAY,EAAA,EAAsD;AAChF,EAAA,IAAI,CAAC,MAAA,EAAQ,OAAO,MAAM,MAAA;AAC1B,EAAA,MAAM,GAAA,GAAM,OAAO,WAAA,CAAY,aAAA,EAAe,CAAC,OAAA,KAAY,EAAA,CAAG,OAAA,CAAQ,QAAQ,CAAC,CAAA;AAC/E,EAAA,OAAO,MAAM,IAAI,MAAA,EAAO;AAC1B;AAEO,SAAS,KAAA,CAAM,gBAAwB,OAAA,EAAsC;AAClF,EAAA,OAAO,MAAA,EAAQ,MAAM,cAAA,EAAgB,OAAA,CAAQ,OAAO,OAAA,CAAQ,YAAY,CAAA,IAAK,OAAA,CAAQ,OAAA,EAAQ;AAC/F;AAEO,SAAS,IAAA,CACd,MAAA,EACA,cAAA,EACA,YAAA,EACe;AACf,EAAA,OAAO,QAAQ,IAAA,CAAK,MAAA,EAAQ,gBAAgB,YAAY,CAAA,IAAK,QAAQ,OAAA,EAAQ;AAC/E;AAEO,IAAM,MAAA,GAAS,MAAY,MAAA,EAAQ,MAAA;AACnC,IAAM,OAAA,GAAU,MAAY,MAAA,EAAQ,OAAA;AACpC,IAAM,MAAA,GAAS,MAAY,MAAA,EAAQ,MAAA;AACnC,IAAM,QAAA,GAAW,CAAC,KAAA,KAAyB,MAAA,EAAQ,SAAS,KAAK;AACjE,IAAM,SAAA,GAAY,CAAC,EAAA,KAAsB,MAAA,EAAQ,UAAU,EAAE;AAC7D,IAAM,YAAA,GAAe,MAAY,MAAA,EAAQ,YAAA;AAEzC,IAAM,UAAA,GAAa,CAAC,EAAA,KAAsB,MAAA,EAAQ,WAAW,EAAE;AAM/D,IAAM,cAAc,CAAC,MAAA,EAAgB,WAC1C,MAAA,EAAQ,WAAA,CAAY,QAAQ,MAAM;AAQ7B,IAAM,cAAA,GAAiB,CAAC,MAAA,EAAgB,MAAA,KAC7C,QAAQ,cAAA,CAAe,MAAA,EAAQ,UAAU,IAAI;AAMxC,IAAM,sBAAA,GAAyB,MAAe,MAAA,EAAQ,sBAAA,EAAuB,IAAK;AAGlF,IAAM,4BAAA,GAA+B,MAAY,MAAA,EAAQ,4BAAA;AAgBhE,IAAI,WAAA;AAGJ,SAAS,eAAA,GAA0D;AACjE,EAAA,IAAI,WAAA,KAAgB,QAAW,OAAO,WAAA;AACtC,EAAA,WAAA,GAAc,IAAA;AACd,EAAA,IAAI,MAAA,EAAQ;AACV,IAAA,IAAI;AACF,MAAA,WAAA,GAAc,wBAAA,CAA2C,eAAe,iBAAiB,CAAA;AAAA,IAC3F,CAAA,CAAA,MAAQ;AACN,MAAA,WAAA,GAAc,IAAA;AAAA,IAChB;AAAA,EACF;AACA,EAAA,OAAO,WAAA;AACT;AAQO,SAAS,cAAc,KAAA,EAAgD;AAC5E,EAAA,MAAM,SAAS,eAAA,EAAgB;AAC/B,EAAA,IAAI,CAAC,MAAA,EAAQ;AACX,IAAA,MAAM,EAAE,aAAA,EAAe,EAAA,EAAI,KAAA,EAAO,EAAA,EAAI,MAAA,EAAQ,EAAA,EAAI,GAAA,EAAK,EAAA,EAAI,GAAG,IAAA,EAAK,GAAI,KAAA;AACvE,IAAA,OAAO,aAAA,CAAc,MAAM,IAAI,CAAA;AAAA,EACjC;AACA,EAAA,OAAO,aAAA,CAAc,QAAQ,KAAK,CAAA;AACpC","file":"index.js","sourcesContent":["// @poolse/react-native-calls — the React Native face of the native\n// call core (CONTRACT.md §8). A thin, typed surface over the native\n// module: commands in, one snapshot event out. All call logic lives in\n// the native cores; nothing here decides anything about a call.\n//\n// On a platform or build without the native module (Expo Go, web,\n// unlinked), every command is a safe no-op and the state stays idle —\n// so a host can render against this unconditionally.\nimport { createElement, type ComponentType, type ReactElement } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { requireNativeViewManager, requireOptionalNativeModule } from 'expo-modules-core';\n\n/**\n * A call's complete state. `null` means idle — no call. After a call\n * ends the snapshot stays `phase: 'ended'` (with `reason`) for ~2.5 s,\n * then becomes `null`.\n */\nexport interface Snapshot {\n cv: 1;\n callId: string;\n conversationId: string;\n conversationName: string | null;\n direction: 'incoming' | 'outgoing' | 'joined';\n media: 'audio' | 'video';\n phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';\n reason?:\n | 'left'\n | 'finished'\n | 'declined_all'\n | 'busy_all'\n | 'no_answer'\n | 'cancelled'\n | 'connection_lost'\n | 'failed';\n /** REMOTE participants only; `userId` is the LiveKit identity. */\n participants: Array<{\n userId: string;\n externalId: string;\n speaking: boolean;\n muted: boolean;\n /** True only with a published, subscribed, unmuted camera track (show video, not avatar). */\n cameraOn: boolean;\n }>;\n audioRoute: 'earpiece' | 'speaker' | 'bluetooth' | 'headset';\n mutedSelf: boolean;\n /** True only while the local camera track is actually published and enabled. */\n cameraOn: boolean;\n /** Facing of the published local camera; absent when the camera is off. */\n cameraFacing?: 'front' | 'back';\n}\n\nexport interface ConfigureOptions {\n /** Drawable resource NAME for the call notification's small icon. */\n smallIcon?: string;\n /** Raw resource NAME of the ringback tone (Android). */\n ringback?: string;\n}\n\nexport interface PlaceOptions {\n media: 'audio' | 'video';\n /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */\n sessionToken: string;\n}\n\ntype NativeSnapshot = { snapshot: Snapshot | null };\n\ninterface PoolseCallsNative {\n configure(apiBaseUrl: string, options: ConfigureOptions | null): Promise<void>;\n getCallState(): NativeSnapshot;\n place(conversationId: string, media: string, sessionToken: string): Promise<void>;\n join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;\n answer(): void;\n decline(): void;\n hangUp(): void;\n setMuted(muted: boolean): void;\n setCamera(on: boolean): void;\n switchCamera(): void;\n setSpeaker(on: boolean): void;\n onRingEnded(callId: string, reason: string): void;\n onCallFinished(callId: string, reason: string | null): void;\n canUseFullScreenIntent(): boolean;\n openFullScreenIntentSettings(): void;\n addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n}\n\nconst native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');\n\n/** True when the native call core is present in this build. */\nexport const nativeCallsAvailable = native != null;\n\n/** Configure once, before any call can arrive. No secrets — just the API origin. */\nexport async function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void> {\n await native?.configure(apiBaseUrl, options ?? null);\n}\n\n/** The current snapshot — safe to read at any moment, including cold answer and before configure. */\nexport function getCallState(): Snapshot | null {\n return native?.getCallState().snapshot ?? null;\n}\n\nexport type Unsubscribe = () => void;\n\n/** Subscribe to call state. Fires a full snapshot on every change. */\nexport function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe {\n if (!native) return () => undefined;\n const sub = native.addListener('onCallState', (payload) => cb(payload.snapshot));\n return () => sub.remove();\n}\n\nexport function place(conversationId: string, options: PlaceOptions): Promise<void> {\n return native?.place(conversationId, options.media, options.sessionToken) ?? Promise.resolve();\n}\n\nexport function join(\n callId: string,\n conversationId: string | null,\n sessionToken: string,\n): Promise<void> {\n return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();\n}\n\nexport const answer = (): void => native?.answer();\nexport const decline = (): void => native?.decline();\nexport const hangUp = (): void => native?.hangUp();\nexport const setMuted = (muted: boolean): void => native?.setMuted(muted);\nexport const setCamera = (on: boolean): void => native?.setCamera(on);\nexport const switchCamera = (): void => native?.switchCamera();\n/** Route call audio to the loudspeaker (`true`) or back to earpiece / headset (`false`). */\nexport const setSpeaker = (on: boolean): void => native?.setSpeaker(on);\n\n/**\n * Socket forwarding for an open app: a `call:ring_ended` seen on the\n * socket. Idempotent with the push path — the core dedupes.\n */\nexport const onRingEnded = (callId: string, reason: string): void =>\n native?.onRingEnded(callId, reason);\n\n/**\n * Socket forwarding: `call:finished`. Pass the payload's `reason`\n * (`finished` | `declined_all` | `no_answer` | `cancelled`) so an\n * unanswered caller sees \"Declined\"/\"No answer\", not \"Call ended\".\n * Idempotent with the `finished` push.\n */\nexport const onCallFinished = (callId: string, reason?: string): void =>\n native?.onCallFinished(callId, reason ?? null);\n\n/**\n * Android 14+: whether the app may show the full-screen ring over the\n * lock screen. `true` on older Android and where the module is absent.\n */\nexport const canUseFullScreenIntent = (): boolean => native?.canUseFullScreenIntent() ?? true;\n\n/** Open the system screen where the user grants full-screen intents (falls back to notification settings). */\nexport const openFullScreenIntentSettings = (): void => native?.openFullScreenIntentSettings();\n\n// ── video ─────────────────────────────────────────────────────────────\n\nexport interface CallVideoViewProps extends ViewProps {\n /** Remote participant's userId (LiveKit identity). Omit with local=true for self view. */\n participantId?: string;\n local?: boolean;\n /** default: true for local front camera, false otherwise */\n mirror?: boolean;\n /** default 'cover' */\n fit?: 'cover' | 'contain';\n}\n\ntype NativeVideoProps = CallVideoViewProps;\n\nlet nativeVideo: ComponentType<NativeVideoProps> | null | undefined;\n\n/** Resolved lazily (first render) and only when the native module exists. */\nfunction nativeVideoView(): ComponentType<NativeVideoProps> | null {\n if (nativeVideo !== undefined) return nativeVideo;\n nativeVideo = null;\n if (native) {\n try {\n nativeVideo = requireNativeViewManager<NativeVideoProps>('PoolseCalls', 'PoolseCallVideo');\n } catch {\n nativeVideo = null; // an older native build without the view\n }\n }\n return nativeVideo;\n}\n\n/**\n * A participant's camera (or, with `local`, your own), rendered natively\n * by the call core. Shows nothing (stays transparent) while that camera\n * is off — draw the avatar from the snapshot's `cameraOn` underneath.\n * Without the native module it renders an empty View.\n */\nexport function CallVideoView(props: CallVideoViewProps): ReactElement | null {\n const Native = nativeVideoView();\n if (!Native) {\n const { participantId: _p, local: _l, mirror: _m, fit: _f, ...rest } = props;\n return createElement(View, rest);\n }\n return createElement(Native, props);\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poolse/react-native-calls",
3
- "version": "0.1.3",
3
+ "version": "0.3.0",
4
4
  "description": "Native call core for poolse — Kotlin/Swift calling engine with a thin React Native bridge. Ring, answer and carry audio with the app killed and the phone locked.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,6 +20,7 @@
20
20
  "src",
21
21
  "dist",
22
22
  "android/build.gradle",
23
+ "android/consumer-rules.pro",
23
24
  "android/src",
24
25
  "expo-module.config.json",
25
26
  "README.md"
@@ -8,4 +8,9 @@
8
8
  // cannot collide with the app's own copy of the types.
9
9
  declare module 'expo-modules-core' {
10
10
  export function requireOptionalNativeModule<T>(name: string): T | null;
11
+ /** A native view registered by an Expo module (`View(...)` + `Name(...)`). */
12
+ export function requireNativeViewManager<P>(
13
+ moduleName: string,
14
+ viewName?: string,
15
+ ): import('react').ComponentType<P>;
11
16
  }
package/src/index.ts CHANGED
@@ -6,9 +6,15 @@
6
6
  // On a platform or build without the native module (Expo Go, web,
7
7
  // unlinked), every command is a safe no-op and the state stays idle —
8
8
  // so a host can render against this unconditionally.
9
- import { requireOptionalNativeModule } from 'expo-modules-core';
9
+ import { createElement, type ComponentType, type ReactElement } from 'react';
10
+ import { View, type ViewProps } from 'react-native';
11
+ import { requireNativeViewManager, requireOptionalNativeModule } from 'expo-modules-core';
10
12
 
11
- /** A call's complete state. `null` means idle — no call. */
13
+ /**
14
+ * A call's complete state. `null` means idle — no call. After a call
15
+ * ends the snapshot stays `phase: 'ended'` (with `reason`) for ~2.5 s,
16
+ * then becomes `null`.
17
+ */
12
18
  export interface Snapshot {
13
19
  cv: 1;
14
20
  callId: string;
@@ -26,16 +32,21 @@ export interface Snapshot {
26
32
  | 'cancelled'
27
33
  | 'connection_lost'
28
34
  | 'failed';
35
+ /** REMOTE participants only; `userId` is the LiveKit identity. */
29
36
  participants: Array<{
30
37
  userId: string;
31
38
  externalId: string;
32
39
  speaking: boolean;
33
40
  muted: boolean;
41
+ /** True only with a published, subscribed, unmuted camera track (show video, not avatar). */
34
42
  cameraOn: boolean;
35
43
  }>;
36
44
  audioRoute: 'earpiece' | 'speaker' | 'bluetooth' | 'headset';
37
45
  mutedSelf: boolean;
46
+ /** True only while the local camera track is actually published and enabled. */
38
47
  cameraOn: boolean;
48
+ /** Facing of the published local camera; absent when the camera is off. */
49
+ cameraFacing?: 'front' | 'back';
39
50
  }
40
51
 
41
52
  export interface ConfigureOptions {
@@ -64,6 +75,11 @@ interface PoolseCallsNative {
64
75
  setMuted(muted: boolean): void;
65
76
  setCamera(on: boolean): void;
66
77
  switchCamera(): void;
78
+ setSpeaker(on: boolean): void;
79
+ onRingEnded(callId: string, reason: string): void;
80
+ onCallFinished(callId: string, reason: string | null): void;
81
+ canUseFullScreenIntent(): boolean;
82
+ openFullScreenIntentSettings(): void;
67
83
  addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };
68
84
  }
69
85
 
@@ -77,7 +93,7 @@ export async function configure(apiBaseUrl: string, options?: ConfigureOptions):
77
93
  await native?.configure(apiBaseUrl, options ?? null);
78
94
  }
79
95
 
80
- /** The current snapshot — safe to read at any moment, including cold answer. */
96
+ /** The current snapshot — safe to read at any moment, including cold answer and before configure. */
81
97
  export function getCallState(): Snapshot | null {
82
98
  return native?.getCallState().snapshot ?? null;
83
99
  }
@@ -109,3 +125,75 @@ export const hangUp = (): void => native?.hangUp();
109
125
  export const setMuted = (muted: boolean): void => native?.setMuted(muted);
110
126
  export const setCamera = (on: boolean): void => native?.setCamera(on);
111
127
  export const switchCamera = (): void => native?.switchCamera();
128
+ /** Route call audio to the loudspeaker (`true`) or back to earpiece / headset (`false`). */
129
+ export const setSpeaker = (on: boolean): void => native?.setSpeaker(on);
130
+
131
+ /**
132
+ * Socket forwarding for an open app: a `call:ring_ended` seen on the
133
+ * socket. Idempotent with the push path — the core dedupes.
134
+ */
135
+ export const onRingEnded = (callId: string, reason: string): void =>
136
+ native?.onRingEnded(callId, reason);
137
+
138
+ /**
139
+ * Socket forwarding: `call:finished`. Pass the payload's `reason`
140
+ * (`finished` | `declined_all` | `no_answer` | `cancelled`) so an
141
+ * unanswered caller sees "Declined"/"No answer", not "Call ended".
142
+ * Idempotent with the `finished` push.
143
+ */
144
+ export const onCallFinished = (callId: string, reason?: string): void =>
145
+ native?.onCallFinished(callId, reason ?? null);
146
+
147
+ /**
148
+ * Android 14+: whether the app may show the full-screen ring over the
149
+ * lock screen. `true` on older Android and where the module is absent.
150
+ */
151
+ export const canUseFullScreenIntent = (): boolean => native?.canUseFullScreenIntent() ?? true;
152
+
153
+ /** Open the system screen where the user grants full-screen intents (falls back to notification settings). */
154
+ export const openFullScreenIntentSettings = (): void => native?.openFullScreenIntentSettings();
155
+
156
+ // ── video ─────────────────────────────────────────────────────────────
157
+
158
+ export interface CallVideoViewProps extends ViewProps {
159
+ /** Remote participant's userId (LiveKit identity). Omit with local=true for self view. */
160
+ participantId?: string;
161
+ local?: boolean;
162
+ /** default: true for local front camera, false otherwise */
163
+ mirror?: boolean;
164
+ /** default 'cover' */
165
+ fit?: 'cover' | 'contain';
166
+ }
167
+
168
+ type NativeVideoProps = CallVideoViewProps;
169
+
170
+ let nativeVideo: ComponentType<NativeVideoProps> | null | undefined;
171
+
172
+ /** Resolved lazily (first render) and only when the native module exists. */
173
+ function nativeVideoView(): ComponentType<NativeVideoProps> | null {
174
+ if (nativeVideo !== undefined) return nativeVideo;
175
+ nativeVideo = null;
176
+ if (native) {
177
+ try {
178
+ nativeVideo = requireNativeViewManager<NativeVideoProps>('PoolseCalls', 'PoolseCallVideo');
179
+ } catch {
180
+ nativeVideo = null; // an older native build without the view
181
+ }
182
+ }
183
+ return nativeVideo;
184
+ }
185
+
186
+ /**
187
+ * A participant's camera (or, with `local`, your own), rendered natively
188
+ * by the call core. Shows nothing (stays transparent) while that camera
189
+ * is off — draw the avatar from the snapshot's `cameraOn` underneath.
190
+ * Without the native module it renders an empty View.
191
+ */
192
+ export function CallVideoView(props: CallVideoViewProps): ReactElement | null {
193
+ const Native = nativeVideoView();
194
+ if (!Native) {
195
+ const { participantId: _p, local: _l, mirror: _m, fit: _f, ...rest } = props;
196
+ return createElement(View, rest);
197
+ }
198
+ return createElement(Native, props);
199
+ }
@@ -0,0 +1,22 @@
1
+ // Minimal ambient declarations for the React / React Native surface this
2
+ // package touches, so it type-checks without either installed in the
3
+ // monorepo (the host app always supplies both). The emitted
4
+ // `dist/index.d.ts` imports the REAL types by module name, so consumers
5
+ // type-check against their own React Native — these shims never ship.
6
+ declare module 'react' {
7
+ export interface ReactElement {
8
+ type: unknown;
9
+ props: unknown;
10
+ key: string | null;
11
+ }
12
+ export type ComponentType<P = object> = (props: P) => ReactElement | null;
13
+ export function createElement(type: unknown, props?: unknown, ...children: unknown[]): ReactElement;
14
+ }
15
+
16
+ declare module 'react-native' {
17
+ export interface ViewProps {
18
+ style?: unknown;
19
+ testID?: string;
20
+ }
21
+ export const View: unknown;
22
+ }