@poolse/react-native-calls 0.4.6 → 0.6.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.
package/dist/index.d.cts CHANGED
@@ -24,13 +24,36 @@ interface Snapshot {
24
24
  /** True only with a published, subscribed, unmuted camera track (show video, not avatar). */
25
25
  cameraOn: boolean;
26
26
  }>;
27
- audioRoute: 'earpiece' | 'speaker' | 'bluetooth' | 'headset';
27
+ /** The kind of output playing now — the short answer to "where is the audio". */
28
+ audioRoute: AudioOutputKind;
29
+ /**
30
+ * Every output the call can play through right now: always the phone
31
+ * (earpiece, or wired headphones in its place) and the speaker, plus
32
+ * each connected Bluetooth / USB device by its own name. Empty until
33
+ * the call's audio is up. Pass an `id` to `setAudioOutput`.
34
+ */
35
+ audioOutputs: AudioOutput[];
36
+ /** The `audioOutputs` entry playing now; null before audio is up. */
37
+ audioOutputId: string | null;
28
38
  mutedSelf: boolean;
29
39
  /** True only while the local camera track is actually published and enabled. */
30
40
  cameraOn: boolean;
31
41
  /** Facing of the published local camera; absent when the camera is off. */
32
42
  cameraFacing?: 'front' | 'back';
33
43
  }
44
+ type AudioOutputKind = 'earpiece' | 'speaker' | 'bluetooth' | 'headset';
45
+ /** One place call audio can play. */
46
+ interface AudioOutput {
47
+ /** Opaque; stable while the device stays connected. */
48
+ id: string;
49
+ kind: AudioOutputKind;
50
+ /**
51
+ * The device's own name for Bluetooth/USB ("AirPods Pro"); a plain
52
+ * English default for built-ins ("iPhone"/"Phone", "Speaker",
53
+ * "Headphones") — relabel those by `kind` to localise.
54
+ */
55
+ name: string;
56
+ }
34
57
  interface ConfigureOptions {
35
58
  /** Drawable resource NAME for the call notification's small icon. */
36
59
  smallIcon?: string;
@@ -54,6 +77,17 @@ interface PlaceOptions {
54
77
  /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */
55
78
  sessionToken: string;
56
79
  }
80
+ /**
81
+ * What the user tapped on a missed-call notification: the notification
82
+ * itself (`open` the conversation) or its Call back button (`call_back`
83
+ * — place a call on the conversation with `media`).
84
+ */
85
+ interface CallAction {
86
+ type: 'open' | 'call_back';
87
+ conversationId: string;
88
+ callId: string | null;
89
+ media: 'audio' | 'video';
90
+ }
57
91
  /** True when the native call core is present in this build. */
58
92
  declare const nativeCallsAvailable: boolean;
59
93
  /** Configure once, before any call can arrive. No secrets — just the API origin. */
@@ -73,6 +107,14 @@ declare const setCamera: (on: boolean) => void;
73
107
  declare const switchCamera: () => void;
74
108
  /** Route call audio to the loudspeaker (`true`) or back to earpiece / headset (`false`). */
75
109
  declare const setSpeaker: (on: boolean) => void;
110
+ /**
111
+ * Play call audio through one of `snapshot.audioOutputs`. The choice
112
+ * holds for the rest of the call, until that device disconnects or a new
113
+ * headset / Bluetooth device connects (which takes over, as in the
114
+ * phone's own dialer). Without a choice a connected headset wins, then
115
+ * the earpiece for voice calls or the speaker for video.
116
+ */
117
+ declare function setAudioOutput(id: string): void;
76
118
  /**
77
119
  * Socket forwarding for an open app: a `call:ring_ended` seen on the
78
120
  * socket. Idempotent with the push path — the core dedupes.
@@ -92,6 +134,20 @@ declare const onCallFinished: (callId: string, reason?: string) => void;
92
134
  declare const canUseFullScreenIntent: () => boolean;
93
135
  /** Open the system screen where the user grants full-screen intents (falls back to notification settings). */
94
136
  declare const openFullScreenIntentSettings: () => void;
137
+ /**
138
+ * Android: the missed-call notification action the app was LAUNCHED
139
+ * with, once — read it on mount. `null` when the app opened any other
140
+ * way. The native core posts that notification itself from the push,
141
+ * so it works with the app closed.
142
+ *
143
+ * iOS shows missed calls as an ordinary alert push (to the token you
144
+ * register with `kind: 'alert'`); read its taps with your notification
145
+ * library — its `type` is `call_missed`, the category
146
+ * `poolse_missed_call`, the Call back action `call_back`.
147
+ */
148
+ declare function takeCallAction(): CallAction | null;
149
+ /** Android: a missed-call notification tapped while the app is running. */
150
+ declare function onCallAction(cb: (action: CallAction) => void): Unsubscribe;
95
151
  /**
96
152
  * iOS: the current PushKit VoIP token, registered natively at launch.
97
153
  * `null` until iOS issues one, and always on Android (FCM carries rings).
@@ -116,4 +172,4 @@ interface CallVideoViewProps extends ViewProps {
116
172
  */
117
173
  declare function CallVideoView(props: CallVideoViewProps): ReactElement | null;
118
174
 
119
- export { CallVideoView, type CallVideoViewProps, type ConfigureOptions, type PlaceOptions, type Snapshot, type Unsubscribe, type VoipToken, answer, canUseFullScreenIntent, configure, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setCamera, setMuted, setSpeaker, switchCamera };
175
+ export { type AudioOutput, type AudioOutputKind, type CallAction, CallVideoView, type CallVideoViewProps, type ConfigureOptions, type PlaceOptions, type Snapshot, type Unsubscribe, type VoipToken, answer, canUseFullScreenIntent, configure, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallAction, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setAudioOutput, setCamera, setMuted, setSpeaker, switchCamera, takeCallAction };
package/dist/index.d.ts CHANGED
@@ -24,13 +24,36 @@ interface Snapshot {
24
24
  /** True only with a published, subscribed, unmuted camera track (show video, not avatar). */
25
25
  cameraOn: boolean;
26
26
  }>;
27
- audioRoute: 'earpiece' | 'speaker' | 'bluetooth' | 'headset';
27
+ /** The kind of output playing now — the short answer to "where is the audio". */
28
+ audioRoute: AudioOutputKind;
29
+ /**
30
+ * Every output the call can play through right now: always the phone
31
+ * (earpiece, or wired headphones in its place) and the speaker, plus
32
+ * each connected Bluetooth / USB device by its own name. Empty until
33
+ * the call's audio is up. Pass an `id` to `setAudioOutput`.
34
+ */
35
+ audioOutputs: AudioOutput[];
36
+ /** The `audioOutputs` entry playing now; null before audio is up. */
37
+ audioOutputId: string | null;
28
38
  mutedSelf: boolean;
29
39
  /** True only while the local camera track is actually published and enabled. */
30
40
  cameraOn: boolean;
31
41
  /** Facing of the published local camera; absent when the camera is off. */
32
42
  cameraFacing?: 'front' | 'back';
33
43
  }
44
+ type AudioOutputKind = 'earpiece' | 'speaker' | 'bluetooth' | 'headset';
45
+ /** One place call audio can play. */
46
+ interface AudioOutput {
47
+ /** Opaque; stable while the device stays connected. */
48
+ id: string;
49
+ kind: AudioOutputKind;
50
+ /**
51
+ * The device's own name for Bluetooth/USB ("AirPods Pro"); a plain
52
+ * English default for built-ins ("iPhone"/"Phone", "Speaker",
53
+ * "Headphones") — relabel those by `kind` to localise.
54
+ */
55
+ name: string;
56
+ }
34
57
  interface ConfigureOptions {
35
58
  /** Drawable resource NAME for the call notification's small icon. */
36
59
  smallIcon?: string;
@@ -54,6 +77,17 @@ interface PlaceOptions {
54
77
  /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */
55
78
  sessionToken: string;
56
79
  }
80
+ /**
81
+ * What the user tapped on a missed-call notification: the notification
82
+ * itself (`open` the conversation) or its Call back button (`call_back`
83
+ * — place a call on the conversation with `media`).
84
+ */
85
+ interface CallAction {
86
+ type: 'open' | 'call_back';
87
+ conversationId: string;
88
+ callId: string | null;
89
+ media: 'audio' | 'video';
90
+ }
57
91
  /** True when the native call core is present in this build. */
58
92
  declare const nativeCallsAvailable: boolean;
59
93
  /** Configure once, before any call can arrive. No secrets — just the API origin. */
@@ -73,6 +107,14 @@ declare const setCamera: (on: boolean) => void;
73
107
  declare const switchCamera: () => void;
74
108
  /** Route call audio to the loudspeaker (`true`) or back to earpiece / headset (`false`). */
75
109
  declare const setSpeaker: (on: boolean) => void;
110
+ /**
111
+ * Play call audio through one of `snapshot.audioOutputs`. The choice
112
+ * holds for the rest of the call, until that device disconnects or a new
113
+ * headset / Bluetooth device connects (which takes over, as in the
114
+ * phone's own dialer). Without a choice a connected headset wins, then
115
+ * the earpiece for voice calls or the speaker for video.
116
+ */
117
+ declare function setAudioOutput(id: string): void;
76
118
  /**
77
119
  * Socket forwarding for an open app: a `call:ring_ended` seen on the
78
120
  * socket. Idempotent with the push path — the core dedupes.
@@ -92,6 +134,20 @@ declare const onCallFinished: (callId: string, reason?: string) => void;
92
134
  declare const canUseFullScreenIntent: () => boolean;
93
135
  /** Open the system screen where the user grants full-screen intents (falls back to notification settings). */
94
136
  declare const openFullScreenIntentSettings: () => void;
137
+ /**
138
+ * Android: the missed-call notification action the app was LAUNCHED
139
+ * with, once — read it on mount. `null` when the app opened any other
140
+ * way. The native core posts that notification itself from the push,
141
+ * so it works with the app closed.
142
+ *
143
+ * iOS shows missed calls as an ordinary alert push (to the token you
144
+ * register with `kind: 'alert'`); read its taps with your notification
145
+ * library — its `type` is `call_missed`, the category
146
+ * `poolse_missed_call`, the Call back action `call_back`.
147
+ */
148
+ declare function takeCallAction(): CallAction | null;
149
+ /** Android: a missed-call notification tapped while the app is running. */
150
+ declare function onCallAction(cb: (action: CallAction) => void): Unsubscribe;
95
151
  /**
96
152
  * iOS: the current PushKit VoIP token, registered natively at launch.
97
153
  * `null` until iOS issues one, and always on Android (FCM carries rings).
@@ -116,4 +172,4 @@ interface CallVideoViewProps extends ViewProps {
116
172
  */
117
173
  declare function CallVideoView(props: CallVideoViewProps): ReactElement | null;
118
174
 
119
- export { CallVideoView, type CallVideoViewProps, type ConfigureOptions, type PlaceOptions, type Snapshot, type Unsubscribe, type VoipToken, answer, canUseFullScreenIntent, configure, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setCamera, setMuted, setSpeaker, switchCamera };
175
+ export { type AudioOutput, type AudioOutputKind, type CallAction, CallVideoView, type CallVideoViewProps, type ConfigureOptions, type PlaceOptions, type Snapshot, type Unsubscribe, type VoipToken, answer, canUseFullScreenIntent, configure, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallAction, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setAudioOutput, setCamera, setMuted, setSpeaker, switchCamera, takeCallAction };
package/dist/index.js CHANGED
@@ -29,10 +29,22 @@ var setMuted = (muted) => native?.setMuted(muted);
29
29
  var setCamera = (on) => native?.setCamera(on);
30
30
  var switchCamera = () => native?.switchCamera();
31
31
  var setSpeaker = (on) => native?.setSpeaker(on);
32
+ function setAudioOutput(id) {
33
+ if (native?.setAudioOutput) native.setAudioOutput(id);
34
+ else native?.setSpeaker(id === "speaker");
35
+ }
32
36
  var onRingEnded = (callId, reason) => native?.onRingEnded(callId, reason);
33
37
  var onCallFinished = (callId, reason) => native?.onCallFinished(callId, reason ?? null);
34
38
  var canUseFullScreenIntent = () => native?.canUseFullScreenIntent() ?? true;
35
39
  var openFullScreenIntentSettings = () => native?.openFullScreenIntentSettings();
40
+ function takeCallAction() {
41
+ return native?.takeCallAction?.() ?? null;
42
+ }
43
+ function onCallAction(cb) {
44
+ if (!native || !native.takeCallAction) return () => void 0;
45
+ const sub = native.addListener("onCallAction", cb);
46
+ return () => sub.remove();
47
+ }
36
48
  function getVoipToken() {
37
49
  return native?.getVoipToken?.() ?? null;
38
50
  }
@@ -63,6 +75,6 @@ function CallVideoView(props) {
63
75
  return createElement(Native, props);
64
76
  }
65
77
 
66
- export { CallVideoView, answer, canUseFullScreenIntent, configure, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setCamera, setMuted, setSpeaker, switchCamera };
78
+ export { CallVideoView, answer, canUseFullScreenIntent, configure, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallAction, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setAudioOutput, setCamera, setMuted, setSpeaker, switchCamera, takeCallAction };
67
79
  //# sourceMappingURL=index.js.map
68
80
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","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 /** Ringback tone: raw resource NAME (Android); main-bundle sound name, mp3/caf/wav (iOS). */\n ringback?: string;\n /**\n * iOS: the APNs environment of this build's `aps-environment`\n * entitlement. Echoed back with the VoIP token so the server pushes to\n * the right gateway. Ignored on Android.\n */\n apnsEnvironment?: 'production' | 'sandbox';\n}\n\n/** The iOS PushKit (VoIP) token and the APNs environment it belongs to. */\nexport interface VoipToken {\n /** Hex-encoded PushKit token. */\n token: string;\n environment: 'production' | 'sandbox' | 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 getVoipToken?(): VoipToken | null;\n addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n addListener(event: 'onVoipToken', cb: (payload: VoipToken) => 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// ── VoIP push (iOS) ───────────────────────────────────────────────────\n\n/**\n * iOS: the current PushKit VoIP token, registered natively at launch.\n * `null` until iOS issues one, and always on Android (FCM carries rings).\n */\nexport function getVoipToken(): VoipToken | null {\n return native?.getVoipToken?.() ?? null;\n}\n\n/** iOS: fires whenever PushKit issues (or rotates) the VoIP token. No-op on Android. */\nexport function onVoipToken(cb: (token: VoipToken) => void): Unsubscribe {\n if (!native || !native.getVoipToken) return () => undefined;\n const sub = native.addListener('onVoipToken', cb);\n return () => sub.remove();\n}\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"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","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 /** The kind of output playing now — the short answer to \"where is the audio\". */\n audioRoute: AudioOutputKind;\n /**\n * Every output the call can play through right now: always the phone\n * (earpiece, or wired headphones in its place) and the speaker, plus\n * each connected Bluetooth / USB device by its own name. Empty until\n * the call's audio is up. Pass an `id` to `setAudioOutput`.\n */\n audioOutputs: AudioOutput[];\n /** The `audioOutputs` entry playing now; null before audio is up. */\n audioOutputId: string | null;\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 type AudioOutputKind = 'earpiece' | 'speaker' | 'bluetooth' | 'headset';\n\n/** One place call audio can play. */\nexport interface AudioOutput {\n /** Opaque; stable while the device stays connected. */\n id: string;\n kind: AudioOutputKind;\n /**\n * The device's own name for Bluetooth/USB (\"AirPods Pro\"); a plain\n * English default for built-ins (\"iPhone\"/\"Phone\", \"Speaker\",\n * \"Headphones\") — relabel those by `kind` to localise.\n */\n name: string;\n}\n\nexport interface ConfigureOptions {\n /** Drawable resource NAME for the call notification's small icon. */\n smallIcon?: string;\n /** Ringback tone: raw resource NAME (Android); main-bundle sound name, mp3/caf/wav (iOS). */\n ringback?: string;\n /**\n * iOS: the APNs environment of this build's `aps-environment`\n * entitlement. Echoed back with the VoIP token so the server pushes to\n * the right gateway. Ignored on Android.\n */\n apnsEnvironment?: 'production' | 'sandbox';\n}\n\n/** The iOS PushKit (VoIP) token and the APNs environment it belongs to. */\nexport interface VoipToken {\n /** Hex-encoded PushKit token. */\n token: string;\n environment: 'production' | 'sandbox' | 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\n/**\n * What the user tapped on a missed-call notification: the notification\n * itself (`open` the conversation) or its Call back button (`call_back`\n * — place a call on the conversation with `media`).\n */\nexport interface CallAction {\n type: 'open' | 'call_back';\n conversationId: string;\n callId: string | null;\n media: 'audio' | 'video';\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 setAudioOutput?(id: string): void;\n onRingEnded(callId: string, reason: string): void;\n onCallFinished(callId: string, reason: string | null): void;\n canUseFullScreenIntent(): boolean;\n openFullScreenIntentSettings(): void;\n getVoipToken?(): VoipToken | null;\n takeCallAction?(): CallAction | null;\n addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n addListener(event: 'onVoipToken', cb: (payload: VoipToken) => void): { remove(): void };\n addListener(event: 'onCallAction', cb: (payload: CallAction) => 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 * Play call audio through one of `snapshot.audioOutputs`. The choice\n * holds for the rest of the call, until that device disconnects or a new\n * headset / Bluetooth device connects (which takes over, as in the\n * phone's own dialer). Without a choice a connected headset wins, then\n * the earpiece for voice calls or the speaker for video.\n */\nexport function setAudioOutput(id: string): void {\n if (native?.setAudioOutput) native.setAudioOutput(id);\n else native?.setSpeaker(id === 'speaker'); // an older native build\n}\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// ── missed-call notifications (Android) ─────────────────────────────────\n\n/**\n * Android: the missed-call notification action the app was LAUNCHED\n * with, once — read it on mount. `null` when the app opened any other\n * way. The native core posts that notification itself from the push,\n * so it works with the app closed.\n *\n * iOS shows missed calls as an ordinary alert push (to the token you\n * register with `kind: 'alert'`); read its taps with your notification\n * library — its `type` is `call_missed`, the category\n * `poolse_missed_call`, the Call back action `call_back`.\n */\nexport function takeCallAction(): CallAction | null {\n return native?.takeCallAction?.() ?? null;\n}\n\n/** Android: a missed-call notification tapped while the app is running. */\nexport function onCallAction(cb: (action: CallAction) => void): Unsubscribe {\n if (!native || !native.takeCallAction) return () => undefined;\n const sub = native.addListener('onCallAction', cb);\n return () => sub.remove();\n}\n\n// ── VoIP push (iOS) ───────────────────────────────────────────────────\n\n/**\n * iOS: the current PushKit VoIP token, registered natively at launch.\n * `null` until iOS issues one, and always on Android (FCM carries rings).\n */\nexport function getVoipToken(): VoipToken | null {\n return native?.getVoipToken?.() ?? null;\n}\n\n/** iOS: fires whenever PushKit issues (or rotates) the VoIP token. No-op on Android. */\nexport function onVoipToken(cb: (token: VoipToken) => void): Unsubscribe {\n if (!native || !native.getVoipToken) return () => undefined;\n const sub = native.addListener('onVoipToken', cb);\n return () => sub.remove();\n}\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"]}
@@ -173,6 +173,7 @@ public final class CallCoreRuntime {
173
173
  public func setCamera(_ on: Bool) { post { [self] in media.setCamera(on) } }
174
174
  public func switchCamera() { post { [self] in media.switchCamera() } }
175
175
  public func setSpeaker(_ on: Bool) { post { [self] in media.setSpeaker(on) } }
176
+ public func setAudioOutput(_ id: String) { post { [self] in media.setAudioOutput(id) } }
176
177
 
177
178
  /// Re-emit the current state (the bridge's roster/route changed).
178
179
  public func reemit() { post { [self] in if state.phase != .idle { sink.emit(state: state) } } }
@@ -71,6 +71,13 @@ public protocol MediaPort: AnyObject {
71
71
  func switchCamera()
72
72
  /// Loudspeaker (`true`) or back to the private route (`false`).
73
73
  func setSpeaker(_ on: Bool)
74
+ /// Play through one specific output — an id from `Snapshot.audioOutputs`.
75
+ func setAudioOutput(_ id: String)
76
+ }
77
+
78
+ public extension MediaPort {
79
+ /// Engines without device selection fall back to the speaker toggle.
80
+ func setAudioOutput(_ id: String) { setSpeaker(id == "speaker") }
74
81
  }
75
82
 
76
83
  // MARK: - Ring surface
@@ -23,6 +23,24 @@ public struct SnapshotParticipant: Equatable {
23
23
  }
24
24
  }
25
25
 
26
+ /// One place call audio can play, as a JS `Snapshot.audioOutputs[]` entry.
27
+ public struct AudioOutput: Equatable {
28
+ /// Stable for the device's lifetime in this call; passed back to `setAudioOutput`.
29
+ public var id: String
30
+ /// `earpiece` | `speaker` | `bluetooth` | `headset`.
31
+ public var kind: String
32
+ /// What the device calls itself ("AirPods Pro"), or a plain default for built-ins.
33
+ public var name: String
34
+
35
+ public init(id: String, kind: String, name: String) {
36
+ self.id = id
37
+ self.kind = kind
38
+ self.name = name
39
+ }
40
+
41
+ public var dictionary: [String: Any] { ["id": id, "kind": kind, "name": name] }
42
+ }
43
+
26
44
  public extension Phase {
27
45
  var wire: String {
28
46
  switch self {
@@ -50,6 +68,8 @@ public extension MachineState {
50
68
  func toSnapshot(conversationName: String? = nil,
51
69
  participants: [SnapshotParticipant] = [],
52
70
  audioRoute: String = "earpiece",
71
+ audioOutputs: [AudioOutput] = [],
72
+ audioOutputId: String? = nil,
53
73
  mutedSelf: Bool = false,
54
74
  cameraOn: Bool = false,
55
75
  cameraFacing: String? = nil) -> [String: Any]? {
@@ -64,6 +84,8 @@ public extension MachineState {
64
84
  "phase": phase.wire,
65
85
  "participants": participants.map { $0.dictionary },
66
86
  "audioRoute": audioRoute,
87
+ "audioOutputs": audioOutputs.map { $0.dictionary },
88
+ "audioOutputId": audioOutputId.map { $0 as Any } ?? NSNull(),
67
89
  "mutedSelf": mutedSelf,
68
90
  "cameraOn": cameraOn,
69
91
  ]
@@ -70,6 +70,7 @@ public class PoolseCallsModule: Module {
70
70
  Function("setCamera") { (on: Bool) in onCore { $0.setCamera(on) } }
71
71
  Function("switchCamera") { onCore { $0.switchCamera() } }
72
72
  Function("setSpeaker") { (on: Bool) in onCore { $0.setSpeaker(on) } }
73
+ Function("setAudioOutput") { (id: String) in onCore { $0.setAudioOutput(id) } }
73
74
 
74
75
  // Socket forwarding (open app): idempotent with the push path.
75
76
  Function("onRingEnded") { (callId: String, reason: String) in
@@ -143,6 +143,8 @@ final class CallCore {
143
143
  private var cameraOn = false
144
144
  private var cameraFacing: String?
145
145
  private var audioRoute: String?
146
+ private var audioOutputs: [AudioOutput] = []
147
+ private var audioOutputId: String?
146
148
 
147
149
  /// ring_ended reasons seen per call — the CallKit end reason.
148
150
  private var ringEndReasons: [String: String] = [:]
@@ -270,6 +272,9 @@ final class CallCore {
270
272
  }
271
273
  }
272
274
 
275
+ /// Not optimistic: the adapter reports the route once iOS has moved it.
276
+ func setAudioOutput(_ id: String) { runtime.setAudioOutput(id) }
277
+
273
278
  /// A video slot for the native view. Main thread.
274
279
  func createVideoSurface() -> LiveKitVideoSurface { mediaPort.createVideoSurface() }
275
280
 
@@ -307,6 +312,8 @@ final class CallCore {
307
312
  conversationName: conversationName,
308
313
  participants: roster,
309
314
  audioRoute: audioRoute ?? (state.media == "video" ? "speaker" : "earpiece"),
315
+ audioOutputs: audioOutputs,
316
+ audioOutputId: audioOutputId,
310
317
  mutedSelf: mutedSelf,
311
318
  cameraOn: cameraOn,
312
319
  cameraFacing: cameraFacing
@@ -326,6 +333,8 @@ final class CallCore {
326
333
  cameraOn = false
327
334
  cameraFacing = nil
328
335
  audioRoute = nil
336
+ audioOutputs = []
337
+ audioOutputId = nil
329
338
  }
330
339
 
331
340
  /// CallKit follows the FINAL state of each dispatch.
@@ -422,6 +431,10 @@ final class CallCore {
422
431
  runtime.post { [weak self] in self?.audioRoute = r }
423
432
  runtime.reemit()
424
433
  }
434
+ fileprivate func outputs(_ list: [AudioOutput], selected: String?) {
435
+ runtime.post { [weak self] in self?.audioOutputs = list; self?.audioOutputId = selected }
436
+ runtime.reemit()
437
+ }
425
438
  fileprivate func localCamera(on: Bool, facing: String?) {
426
439
  runtime.post { [weak self] in self?.cameraOn = on; self?.cameraFacing = facing }
427
440
  runtime.reemit()
@@ -497,6 +510,7 @@ private final class EventsAdapter: MediaEvents {
497
510
  func onParticipants(_ participants: [SnapshotParticipant]) { core?.participants(participants) }
498
511
  func onAudioRoute(_ route: String) { core?.route(route) }
499
512
  func onLocalCamera(on: Bool, facing: String?) { core?.localCamera(on: on, facing: facing) }
513
+ func onAudioOutputs(_ outputs: [AudioOutput], selectedId: String?) { core?.outputs(outputs, selected: selectedId) }
500
514
  }
501
515
 
502
516
  /// Host-facing entry points.