@repzo/widget-react-native 1.2.2 → 1.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 (97) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +35 -29
  3. package/android/build.gradle +22 -0
  4. package/android/src/main/java/expo/modules/repzowidget/RepzoVoicePlayer.kt +222 -0
  5. package/android/src/main/java/expo/modules/repzowidget/RepzoWidgetAudioModule.kt +68 -0
  6. package/expo-module.config.json +9 -0
  7. package/ios/RepzoVoicePlayer.swift +175 -0
  8. package/ios/RepzoWidgetAudio.podspec +26 -0
  9. package/ios/RepzoWidgetAudioModule.swift +65 -0
  10. package/lib/commonjs/audio/native-player.js +97 -0
  11. package/lib/commonjs/audio/native-player.js.map +1 -0
  12. package/lib/commonjs/audio/voice.js +34 -7
  13. package/lib/commonjs/audio/voice.js.map +1 -1
  14. package/lib/commonjs/state/widget-store.js +23 -1
  15. package/lib/commonjs/state/widget-store.js.map +1 -1
  16. package/lib/commonjs/ui/WidgetProvider.js +1 -3
  17. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  18. package/lib/commonjs/ui/components/ImageViewer.js +17 -35
  19. package/lib/commonjs/ui/components/ImageViewer.js.map +1 -1
  20. package/lib/commonjs/ui/components/MessagesSkeleton.js +126 -0
  21. package/lib/commonjs/ui/components/MessagesSkeleton.js.map +1 -0
  22. package/lib/commonjs/ui/components/VoiceNote.js +8 -9
  23. package/lib/commonjs/ui/components/VoiceNote.js.map +1 -1
  24. package/lib/commonjs/ui/components/ZoomableImage.js +299 -0
  25. package/lib/commonjs/ui/components/ZoomableImage.js.map +1 -0
  26. package/lib/commonjs/ui/screens/chat/ChatView.js +6 -4
  27. package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
  28. package/lib/commonjs/ui/screens/chat/ChatView.styles.js +5 -7
  29. package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
  30. package/lib/module/audio/native-player.js +93 -0
  31. package/lib/module/audio/native-player.js.map +1 -0
  32. package/lib/module/audio/voice.js +32 -6
  33. package/lib/module/audio/voice.js.map +1 -1
  34. package/lib/module/state/widget-store.js +23 -1
  35. package/lib/module/state/widget-store.js.map +1 -1
  36. package/lib/module/ui/WidgetProvider.js +1 -3
  37. package/lib/module/ui/WidgetProvider.js.map +1 -1
  38. package/lib/module/ui/components/ImageViewer.js +18 -36
  39. package/lib/module/ui/components/ImageViewer.js.map +1 -1
  40. package/lib/module/ui/components/MessagesSkeleton.js +124 -0
  41. package/lib/module/ui/components/MessagesSkeleton.js.map +1 -0
  42. package/lib/module/ui/components/VoiceNote.js +9 -10
  43. package/lib/module/ui/components/VoiceNote.js.map +1 -1
  44. package/lib/module/ui/components/ZoomableImage.js +291 -0
  45. package/lib/module/ui/components/ZoomableImage.js.map +1 -0
  46. package/lib/module/ui/screens/chat/ChatView.js +6 -4
  47. package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
  48. package/lib/module/ui/screens/chat/ChatView.styles.js +5 -7
  49. package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
  50. package/lib/typescript/commonjs/audio/native-player.d.ts +36 -0
  51. package/lib/typescript/commonjs/audio/native-player.d.ts.map +1 -0
  52. package/lib/typescript/commonjs/audio/voice.d.ts +17 -4
  53. package/lib/typescript/commonjs/audio/voice.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/state/widget-store.d.ts +11 -0
  55. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  57. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts +2 -2
  58. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/ui/components/MessagesSkeleton.d.ts +10 -0
  60. package/lib/typescript/commonjs/ui/components/MessagesSkeleton.d.ts.map +1 -0
  61. package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts +2 -2
  62. package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts +45 -0
  64. package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts.map +1 -0
  65. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +4 -7
  67. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  68. package/lib/typescript/module/audio/native-player.d.ts +36 -0
  69. package/lib/typescript/module/audio/native-player.d.ts.map +1 -0
  70. package/lib/typescript/module/audio/voice.d.ts +17 -4
  71. package/lib/typescript/module/audio/voice.d.ts.map +1 -1
  72. package/lib/typescript/module/state/widget-store.d.ts +11 -0
  73. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  74. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  75. package/lib/typescript/module/ui/components/ImageViewer.d.ts +2 -2
  76. package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -1
  77. package/lib/typescript/module/ui/components/MessagesSkeleton.d.ts +10 -0
  78. package/lib/typescript/module/ui/components/MessagesSkeleton.d.ts.map +1 -0
  79. package/lib/typescript/module/ui/components/VoiceNote.d.ts +2 -2
  80. package/lib/typescript/module/ui/components/VoiceNote.d.ts.map +1 -1
  81. package/lib/typescript/module/ui/components/ZoomableImage.d.ts +45 -0
  82. package/lib/typescript/module/ui/components/ZoomableImage.d.ts.map +1 -0
  83. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
  84. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +4 -7
  85. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  86. package/package.json +10 -3
  87. package/react-native.config.js +12 -0
  88. package/src/audio/native-player.ts +106 -0
  89. package/src/audio/voice.ts +30 -6
  90. package/src/state/widget-store.ts +24 -1
  91. package/src/ui/WidgetProvider.tsx +1 -3
  92. package/src/ui/components/ImageViewer.tsx +11 -30
  93. package/src/ui/components/MessagesSkeleton.tsx +92 -0
  94. package/src/ui/components/VoiceNote.tsx +6 -14
  95. package/src/ui/components/ZoomableImage.tsx +250 -0
  96. package/src/ui/screens/chat/ChatView.styles.ts +5 -7
  97. package/src/ui/screens/chat/ChatView.tsx +4 -3
package/CHANGELOG.md CHANGED
@@ -12,6 +12,47 @@ All notable changes to `@repzo/widget-react-native`, newest first.
12
12
 
13
13
  ## Unreleased
14
14
 
15
+ ## 1.3.0 — 2026-10-27
16
+
17
+ ### Changed
18
+
19
+ - **Voice messages play without `expo-audio`.** The SDK now brings its own
20
+ small player on the phone's audio player (AVPlayer on iOS, MediaPlayer on
21
+ Android), with no extra libraries or permissions. In apps with Expo modules
22
+ it links automatically on the next native build. On iOS, notes now play with
23
+ the ringer switch on silent and pause music while they play, like in
24
+ messaging apps; an app that set its own audio session category (including
25
+ `ambient`) keeps it. A note pauses when the visitor leaves the app, and when
26
+ a call, navigation prompt or another app's audio starts.
27
+ Where the player isn't built in (Expo Go, bare React Native without Expo
28
+ modules), notes still play with `expo-audio` if the app has it, or open in
29
+ the browser.
30
+ - **Placeholder bubbles while a conversation loads.** Opening a conversation
31
+ shows the outline of a thread, gently pulsing, until its messages arrive,
32
+ instead of a "Loading..." line. It holds still when the device has Reduce
33
+ Motion on.
34
+ - **Zoom into images.** In the full-screen image viewer, visitors can pinch
35
+ to zoom, drag a zoomed image around, and double-tap to zoom in on a spot
36
+ (and again to zoom out). This now works on Android too; before, only iOS
37
+ had pinch-to-zoom. No new dependency.
38
+
39
+ ### Fixed
40
+
41
+ - **Back from a conversation returns to where the visitor opened it.** A
42
+ conversation opened from the home screen (a recent conversation or "send us
43
+ a message") now goes back to home instead of the conversation list. One
44
+ opened from the list still goes back to the list, and one opened with the
45
+ messenger closed (a push or a notification card) goes to the list as before.
46
+
47
+ ### Upgrading
48
+
49
+ - **A native build picks up the voice player.** Nothing else to do: build the
50
+ app again (development or store build) after upgrading. `expo-audio` can be
51
+ removed if nothing else in your app uses it. Until the new build, an
52
+ over-the-air update with this version keeps using `expo-audio` or the file
53
+ row. If your `runtimeVersion` uses the `fingerprint` policy, it changes with
54
+ this update.
55
+
15
56
  ## 1.2.2 — 2026-10-26
16
57
 
17
58
  ### Added
package/README.md CHANGED
@@ -7,9 +7,10 @@
7
7
  ![Expo Go](https://img.shields.io/badge/Expo%20Go-supported-000020.svg)
8
8
 
9
9
  The Repzo chat widget for React Native — an Intercom-style messenger that drops
10
- into any app. Pure JavaScript/TypeScript: **no native modules of its own, no
11
- config plugin, no `pod install`**. Works on iOS and Android, Expo and bare
12
- React Native, old and new architecture.
10
+ into any app. Pure JavaScript/TypeScript, apart from one small optional
11
+ native piece (the voice message player, linked automatically in Expo apps): **no
12
+ config plugin and nothing extra to set up**. Works on iOS and Android, Expo and
13
+ bare React Native, old and new architecture.
13
14
 
14
15
  It connects to the same channel as your Repzo website chat (Workstation →
15
16
  Settings → Inbox Channels → Website Chat), so a message sent from the app lands
@@ -24,8 +25,9 @@ in your Repzo Inbox and agent replies arrive live.
24
25
  - Sent/Seen receipts, teammate avatars and presence, deleted-message placeholders
25
26
  - Catches up after a dropped connection or a trip to the background — no missed or duplicated replies
26
27
  - Agent UI blocks: product cards, slot pickers, lead forms, quotes, receipts
28
+ - **Image viewer** with pinch and double-tap zoom for photos in the conversation
27
29
  - **Photo and file attachments** (optional — with `expo-image-picker` / `expo-document-picker`, or your own picker)
28
- - **Voice messages play in the chat** (optional — with `expo-audio`)
30
+ - **Voice messages play in the chat**, with the phone's own audio player — nothing to install in Expo apps
29
31
  - **In-app notification cards** for new replies while the messenger is closed
30
32
  - **Posts** from Workstation over the app, with replies or emoji reactions
31
33
  - **Push notifications via FCM** (optional — needs `@react-native-firebase/messaging` in the host app), including Mobile Push campaigns from Workstation with images
@@ -46,7 +48,7 @@ in your Repzo Inbox and agent replies arrive live.
46
48
  | `expo-document-picker` | optional — "File" attachments |
47
49
  | `expo-file-system` | optional — reads picked files as base64 (falls back to `fetch` without it) |
48
50
  | `expo-image-manipulator` | optional — shrinks photos so camera shots fit the 1.5 MB attachment limit |
49
- | `expo-audio` | optional (*1.2.2+*) — plays voice messages and audio files inside the chat (Expo SDK 53+) |
51
+ | `expo-audio` | optional (*1.2.2+*) — no longer needed from *1.3.0+* in apps with Expo modules (see "Voice messages"); still used where the SDK's own player isn't built in |
50
52
  | `expo-crypto` | optional (*1.1.0+*) — secure random source for the anonymous device id when the runtime has no `crypto` global (see below) |
51
53
 
52
54
  Realtime uses React Native’s built-in streaming XMLHttpRequest; there are no bundled runtime dependencies.
@@ -380,30 +382,33 @@ the app regardless of the channel setting, pass `attachments: { enabled: false }
380
382
 
381
383
  *1.2.2+.* Voice messages from your team, and any other audio file in the
382
384
  conversation, play right in the chat — a play button, a waveform to tap or
383
- drag through, and 1×/1.5×/2× speed — once your app has `expo-audio`:
384
-
385
- ```sh
386
- npx expo install expo-audio
387
- ```
388
-
389
- It's a native module, so rebuild the app (a new development build or store
390
- build; an over-the-air update alone isn't enough). Without it, audio shows as a
391
- file the visitor taps to open in the browser, as before. A note only downloads
392
- once the visitor taps play, starting one pauses any other, and a note that
393
- can't be played falls back to that file.
394
-
395
- The widget leaves your app's audio session alone, so notes follow your
396
- expo-audio settings. On iOS that means they're muted while the ringer switch is
397
- on silent, unless your app turns that on:
398
-
399
- ```ts
400
- import { setAudioModeAsync } from 'expo-audio';
401
-
402
- await setAudioModeAsync({ playsInSilentMode: true });
403
- ```
404
-
405
- On iOS, `setAudioModeAsync` resets any field you leave out to its default, so
406
- pass the rest of your app's audio mode along with it.
385
+ drag through, and 1×/1.5×/2× speed.
386
+
387
+ *1.3.0+.* The SDK brings its own small player, built on the phone's audio
388
+ player (AVPlayer on iOS, MediaPlayer on Android), so there's nothing to
389
+ install. In an app with Expo modules (any Expo app, or bare React Native with
390
+ `expo` installed) it's linked automatically the next time you build the app (a
391
+ new development build or store build; an over-the-air update alone isn't
392
+ enough). It adds no permissions and no other libraries.
393
+
394
+ Where the SDK's player isn't built in — Expo Go, bare React Native without
395
+ Expo modules, or an app binary built with 1.2.2 or older — notes play with
396
+ `expo-audio` if your app has it, and otherwise show as a file the visitor taps
397
+ to open in the browser, as before. A note only downloads once the visitor taps
398
+ play, starting one pauses any other, and a note that can't be played falls
399
+ back to that file.
400
+
401
+ With the SDK's player, notes play like in messaging apps: on iOS they play
402
+ with the ringer switch on silent, and they pause music while they play and
403
+ let it carry on after. A note pauses when the visitor leaves the app, and when
404
+ a phone call, a navigation prompt or another app's audio starts. If your app
405
+ has set its own iOS audio session category (including `ambient`), the player
406
+ keeps it.
407
+
408
+ With `expo-audio` instead, the widget leaves your app's audio session alone, so
409
+ notes follow your expo-audio settings. On iOS that means they're muted while the
410
+ ringer switch is on silent, unless your app calls
411
+ `setAudioModeAsync({ playsInSilentMode: true })` from `expo-audio`.
407
412
 
408
413
  ## Errors
409
414
 
@@ -730,6 +735,7 @@ action from you:
730
735
  | 1.0.0 | In-app notification cards are now on by default (`inAppNotifications: false` turns them off). The attach button appears if `expo-image-picker` or `expo-document-picker` is installed (`attachments: { enabled: false }` hides it). The `withRepzoWidget` Metro wrapper is optional now. |
731
736
  | 1.0.9 or older | Nothing required. Development builds now warn once if the visitor token isn't in secure storage, or if the device has no secure random source — see "Secure token storage" and "Requirements". |
732
737
  | 1.1.0 or older | Posts from Workstation now show over the app by default, once one is set live for Mobile (`posts: false` turns them off). They need `<RepzoWidgetProvider>` mounted at the root. |
738
+ | 1.2.2 or older | Nothing required. In apps with Expo modules, the SDK now includes a small native voice player, so build the app again (development or store build) to get it; `expo-audio` can be removed if nothing else in your app uses it. If your `runtimeVersion` uses the `fingerprint` policy, it changes with this update. |
733
739
 
734
740
  ## Troubleshooting
735
741
 
@@ -0,0 +1,22 @@
1
+ apply plugin: 'com.android.library'
2
+
3
+ group = 'expo.modules.repzowidget'
4
+ version = '1.0.0'
5
+
6
+ // The way Expo modules were built before Expo SDK 52, which newer SDKs still
7
+ // support. The `expo-module-gradle-plugin` form would only build on SDK 52+.
8
+ def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
9
+ apply from: expoModulesCorePlugin
10
+ applyKotlinExpoModulesCorePlugin()
11
+ useCoreDependencies()
12
+ useDefaultAndroidSdkVersions()
13
+
14
+ android {
15
+ namespace "expo.modules.repzowidget"
16
+ defaultConfig {
17
+ versionCode 1
18
+ versionName "1.0.0"
19
+ }
20
+ }
21
+
22
+ // No other dependencies: voice notes play through Android's own MediaPlayer.
@@ -0,0 +1,222 @@
1
+ package expo.modules.repzowidget
2
+
3
+ import android.content.Context
4
+ import android.media.AudioAttributes
5
+ import android.media.AudioFocusRequest
6
+ import android.media.AudioManager
7
+ import android.media.MediaPlayer
8
+ import android.os.Build
9
+ import android.os.Handler
10
+ import android.os.Looper
11
+
12
+ /**
13
+ * One voice note on Android's own MediaPlayer. Created, driven and released
14
+ * on the main thread, so MediaPlayer's callbacks arrive there too.
15
+ */
16
+ internal class RepzoVoicePlayer(
17
+ context: Context,
18
+ url: String,
19
+ private val onStatus: (Map<String, Any?>) -> Unit
20
+ ) {
21
+ private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
22
+ private val handler = Handler(Looper.getMainLooper())
23
+ private val player = MediaPlayer()
24
+ private val attributes = AudioAttributes.Builder()
25
+ .setUsage(AudioAttributes.USAGE_MEDIA)
26
+ .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
27
+ .build()
28
+
29
+ private var prepared = false
30
+ private var released = false
31
+ // The visitor asked for playback; it starts once the note has loaded.
32
+ private var wantsPlay = false
33
+ private var buffering = false
34
+ private var error: String? = null
35
+ private var rate = 1f
36
+ private var pendingSeek: (() -> Unit)? = null
37
+ private var focusRequest: AudioFocusRequest? = null
38
+
39
+ // A call or another app taking the audio pauses the note, as messaging apps do.
40
+ // That includes a request to duck (navigation prompts): Android doesn't duck
41
+ // speech for us, and two voices at once can't be followed.
42
+ private val focusListener = AudioManager.OnAudioFocusChangeListener { change ->
43
+ when (change) {
44
+ AudioManager.AUDIOFOCUS_LOSS,
45
+ AudioManager.AUDIOFOCUS_LOSS_TRANSIENT,
46
+ AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> pause()
47
+ }
48
+ }
49
+
50
+ // Moves the playhead while the note plays.
51
+ private val tick = object : Runnable {
52
+ override fun run() {
53
+ if (released) return
54
+ emit()
55
+ if (isPlaying()) handler.postDelayed(this, TICK_MS)
56
+ }
57
+ }
58
+
59
+ init {
60
+ player.setAudioAttributes(attributes)
61
+ player.setOnPreparedListener {
62
+ prepared = true
63
+ if (wantsPlay) startPlayback() else emit()
64
+ }
65
+ player.setOnCompletionListener {
66
+ wantsPlay = false
67
+ handler.removeCallbacks(tick)
68
+ abandonFocus()
69
+ emit(didJustFinish = true)
70
+ }
71
+ player.setOnErrorListener { _, what, extra ->
72
+ error = "Can't play this audio ($what/$extra)"
73
+ prepared = false
74
+ wantsPlay = false
75
+ handler.removeCallbacks(tick)
76
+ abandonFocus()
77
+ emit()
78
+ true
79
+ }
80
+ player.setOnInfoListener { _, what, _ ->
81
+ when (what) {
82
+ MediaPlayer.MEDIA_INFO_BUFFERING_START -> { buffering = true; emit() }
83
+ MediaPlayer.MEDIA_INFO_BUFFERING_END -> { buffering = false; emit() }
84
+ }
85
+ false
86
+ }
87
+ player.setOnSeekCompleteListener {
88
+ val done = pendingSeek
89
+ pendingSeek = null
90
+ emit()
91
+ done?.invoke()
92
+ }
93
+ try {
94
+ player.setDataSource(url)
95
+ player.prepareAsync()
96
+ } catch (e: Exception) {
97
+ error = e.message ?: "Can't play this audio"
98
+ handler.post { emit() }
99
+ }
100
+ }
101
+
102
+ fun play() {
103
+ if (released) return
104
+ wantsPlay = true
105
+ if (prepared) startPlayback() else emit()
106
+ }
107
+
108
+ fun pause() {
109
+ if (released) return
110
+ wantsPlay = false
111
+ if (isPlaying()) player.pause()
112
+ handler.removeCallbacks(tick)
113
+ abandonFocus()
114
+ emit()
115
+ }
116
+
117
+ fun seekTo(seconds: Double, done: () -> Unit) {
118
+ if (released || !prepared) {
119
+ done()
120
+ return
121
+ }
122
+ // A seek that hasn't landed yet is overtaken by this one.
123
+ pendingSeek?.invoke()
124
+ pendingSeek = done
125
+ val ms = (seconds.coerceAtLeast(0.0) * 1000).toLong()
126
+ try {
127
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
128
+ player.seekTo(ms, MediaPlayer.SEEK_CLOSEST)
129
+ } else {
130
+ player.seekTo(ms.toInt())
131
+ }
132
+ } catch (e: IllegalStateException) {
133
+ pendingSeek = null
134
+ done()
135
+ }
136
+ }
137
+
138
+ fun setRate(value: Float) {
139
+ rate = value
140
+ // Setting the speed of a paused MediaPlayer starts it, so a paused note
141
+ // keeps the new speed for its next start instead.
142
+ if (isPlaying()) applyRate()
143
+ }
144
+
145
+ fun release() {
146
+ if (released) return
147
+ released = true
148
+ handler.removeCallbacks(tick)
149
+ abandonFocus()
150
+ pendingSeek?.invoke()
151
+ pendingSeek = null
152
+ player.release()
153
+ }
154
+
155
+ private fun startPlayback() {
156
+ if (released || !prepared) return
157
+ requestFocus()
158
+ try {
159
+ applyRate()
160
+ if (!player.isPlaying) player.start()
161
+ } catch (e: IllegalStateException) {
162
+ error = e.message ?: "Can't play this audio"
163
+ }
164
+ handler.removeCallbacks(tick)
165
+ handler.post(tick)
166
+ }
167
+
168
+ private fun applyRate() {
169
+ try {
170
+ player.playbackParams = player.playbackParams.setSpeed(rate)
171
+ } catch (e: Exception) {
172
+ // Speed unsupported for this note: it plays at 1×.
173
+ }
174
+ }
175
+
176
+ private fun isPlaying(): Boolean =
177
+ prepared && !released && try { player.isPlaying } catch (e: IllegalStateException) { false }
178
+
179
+ private fun requestFocus() {
180
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
181
+ val request = focusRequest ?: AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
182
+ .setAudioAttributes(attributes)
183
+ .setOnAudioFocusChangeListener(focusListener, handler)
184
+ .build()
185
+ .also { focusRequest = it }
186
+ audioManager.requestAudioFocus(request)
187
+ } else {
188
+ @Suppress("DEPRECATION")
189
+ audioManager.requestAudioFocus(focusListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
190
+ }
191
+ }
192
+
193
+ private fun abandonFocus() {
194
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
195
+ focusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
196
+ } else {
197
+ @Suppress("DEPRECATION")
198
+ audioManager.abandonAudioFocus(focusListener)
199
+ }
200
+ }
201
+
202
+ private fun emit(didJustFinish: Boolean = false) {
203
+ if (released) return
204
+ val duration = if (prepared) player.duration.coerceAtLeast(0) / 1000.0 else 0.0
205
+ val position = if (prepared) player.currentPosition.coerceAtLeast(0) / 1000.0 else 0.0
206
+ onStatus(
207
+ mapOf(
208
+ "currentTime" to position,
209
+ "duration" to duration,
210
+ "playing" to isPlaying(),
211
+ "isLoaded" to prepared,
212
+ "isBuffering" to (buffering || (wantsPlay && !prepared && error == null)),
213
+ "didJustFinish" to didJustFinish,
214
+ "error" to error
215
+ )
216
+ )
217
+ }
218
+
219
+ private companion object {
220
+ const val TICK_MS = 100L
221
+ }
222
+ }
@@ -0,0 +1,68 @@
1
+ package expo.modules.repzowidget
2
+
3
+ import android.os.Handler
4
+ import android.os.Looper
5
+ import expo.modules.kotlin.Promise
6
+ import expo.modules.kotlin.exception.Exceptions
7
+ import expo.modules.kotlin.functions.Queues
8
+ import expo.modules.kotlin.modules.Module
9
+ import expo.modules.kotlin.modules.ModuleDefinition
10
+
11
+ /**
12
+ * Plays the chat's voice notes, so apps don't need an audio package of their
13
+ * own. JS addresses each note's player by an id it picks (see
14
+ * `src/audio/native-player.ts`); every change comes back as an `onStatus`
15
+ * event carrying that id.
16
+ */
17
+ class RepzoWidgetAudioModule : Module() {
18
+ // Only touched on the main thread: every function below runs there.
19
+ private val players = HashMap<String, RepzoVoicePlayer>()
20
+
21
+ override fun definition() = ModuleDefinition {
22
+ Name("RepzoWidgetAudio")
23
+
24
+ Events("onStatus")
25
+
26
+ AsyncFunction("create") { id: String, url: String ->
27
+ val context = appContext.reactContext ?: throw Exceptions.ReactContextLost()
28
+ players.remove(id)?.release()
29
+ players[id] = RepzoVoicePlayer(context, url) { status ->
30
+ sendEvent("onStatus", status + ("id" to id))
31
+ }
32
+ }.runOnQueue(Queues.MAIN)
33
+
34
+ AsyncFunction("play") { id: String ->
35
+ players[id]?.play()
36
+ }.runOnQueue(Queues.MAIN)
37
+
38
+ AsyncFunction("pause") { id: String ->
39
+ players[id]?.pause()
40
+ }.runOnQueue(Queues.MAIN)
41
+
42
+ AsyncFunction("seekTo") { id: String, seconds: Double, promise: Promise ->
43
+ val player = players[id]
44
+ if (player == null) promise.resolve(null) else player.seekTo(seconds) { promise.resolve(null) }
45
+ }.runOnQueue(Queues.MAIN)
46
+
47
+ AsyncFunction("setRate") { id: String, rate: Double ->
48
+ players[id]?.setRate(rate.toFloat())
49
+ }.runOnQueue(Queues.MAIN)
50
+
51
+ AsyncFunction("release") { id: String ->
52
+ players.remove(id)?.release()
53
+ }.runOnQueue(Queues.MAIN)
54
+
55
+ // Leaving the app pauses every note, including one still loading, so it
56
+ // doesn't keep playing with its controls out of sight.
57
+ OnActivityEntersBackground {
58
+ players.values.forEach { it.pause() }
59
+ }
60
+
61
+ OnDestroy {
62
+ Handler(Looper.getMainLooper()).post {
63
+ players.values.forEach { it.release() }
64
+ players.clear()
65
+ }
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "platforms": ["apple", "android"],
3
+ "apple": {
4
+ "modules": ["RepzoWidgetAudioModule"]
5
+ },
6
+ "android": {
7
+ "modules": ["expo.modules.repzowidget.RepzoWidgetAudioModule"]
8
+ }
9
+ }
@@ -0,0 +1,175 @@
1
+ import AVFoundation
2
+
3
+ /**
4
+ * One voice note on the system AVPlayer. Created, driven and released on the
5
+ * main queue; observers hop back to it before touching anything.
6
+ */
7
+ final class RepzoVoicePlayer {
8
+ private let player: AVPlayer
9
+ private let item: AVPlayerItem
10
+ private let onStatus: ([String: Any?]) -> Void
11
+ private var rate: Float = 1
12
+ private var released = false
13
+ private var timeObserver: Any?
14
+ private var observations: [NSKeyValueObservation] = []
15
+ private var notificationObservers: [NSObjectProtocol] = []
16
+
17
+ init(url: URL, onStatus: @escaping ([String: Any?]) -> Void) {
18
+ self.onStatus = onStatus
19
+ item = AVPlayerItem(url: url)
20
+ // Keeps voices at their normal pitch at 1.5× and 2×.
21
+ item.audioTimePitchAlgorithm = .timeDomain
22
+ player = AVPlayer(playerItem: item)
23
+
24
+ observations = [
25
+ item.observe(\.status) { [weak self] _, _ in self?.emitOnMain() },
26
+ item.observe(\.duration) { [weak self] _, _ in self?.emitOnMain() },
27
+ player.observe(\.timeControlStatus) { [weak self] _, _ in self?.emitOnMain() },
28
+ ]
29
+ // Moves the playhead while the note plays.
30
+ timeObserver = player.addPeriodicTimeObserver(
31
+ forInterval: CMTime(value: 1, timescale: 10),
32
+ queue: .main
33
+ ) { [weak self] _ in
34
+ guard let self = self, self.player.timeControlStatus == .playing else { return }
35
+ self.emit()
36
+ }
37
+ let center = NotificationCenter.default
38
+ notificationObservers = [
39
+ center.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main) { [weak self] _ in
40
+ self?.finish()
41
+ },
42
+ center.addObserver(forName: .AVPlayerItemFailedToPlayToEndTime, object: item, queue: .main) { [weak self] _ in
43
+ self?.emit()
44
+ },
45
+ // A call or another app taking the audio pauses the note, as messaging apps do.
46
+ center.addObserver(forName: AVAudioSession.interruptionNotification, object: nil, queue: .main) { [weak self] note in
47
+ let type = (note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt).flatMap(AVAudioSession.InterruptionType.init)
48
+ if type == .began { self?.pause() }
49
+ },
50
+ ]
51
+ }
52
+
53
+ func play() {
54
+ guard !released else { return }
55
+ RepzoVoiceSession.shared.begin(self)
56
+ let duration = item.duration.seconds
57
+ if duration.isFinite, duration > 0, player.currentTime().seconds >= duration - 0.05 {
58
+ player.seek(to: .zero)
59
+ }
60
+ // Starts once enough has loaded; until then timeControlStatus reports waiting.
61
+ player.rate = rate
62
+ emit()
63
+ }
64
+
65
+ func pause() {
66
+ guard !released else { return }
67
+ player.pause()
68
+ RepzoVoiceSession.shared.end(self)
69
+ emit()
70
+ }
71
+
72
+ func seek(to seconds: Double, completion: @escaping () -> Void) {
73
+ guard !released else {
74
+ completion()
75
+ return
76
+ }
77
+ let time = CMTime(seconds: max(0, seconds), preferredTimescale: 600)
78
+ player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in
79
+ DispatchQueue.main.async {
80
+ self?.emit()
81
+ completion()
82
+ }
83
+ }
84
+ }
85
+
86
+ func setRate(_ value: Float) {
87
+ rate = value
88
+ // Setting the rate of a paused AVPlayer would start it.
89
+ if player.timeControlStatus != .paused { player.rate = value }
90
+ }
91
+
92
+ func release() {
93
+ guard !released else { return }
94
+ player.pause()
95
+ released = true
96
+ if let timeObserver = timeObserver { player.removeTimeObserver(timeObserver) }
97
+ timeObserver = nil
98
+ observations.forEach { $0.invalidate() }
99
+ observations = []
100
+ notificationObservers.forEach { NotificationCenter.default.removeObserver($0) }
101
+ notificationObservers = []
102
+ player.replaceCurrentItem(with: nil)
103
+ RepzoVoiceSession.shared.end(self)
104
+ }
105
+
106
+ private func finish() {
107
+ guard !released else { return }
108
+ player.pause()
109
+ RepzoVoiceSession.shared.end(self)
110
+ emit(didJustFinish: true)
111
+ }
112
+
113
+ private func emitOnMain() {
114
+ if Thread.isMainThread {
115
+ emit()
116
+ } else {
117
+ DispatchQueue.main.async { [weak self] in self?.emit() }
118
+ }
119
+ }
120
+
121
+ private func emit(didJustFinish: Bool = false) {
122
+ guard !released else { return }
123
+ let duration = item.duration.seconds
124
+ let current = player.currentTime().seconds
125
+ var error: String?
126
+ if item.status == .failed {
127
+ error = item.error?.localizedDescription ?? "Can't play this audio"
128
+ }
129
+ onStatus([
130
+ "currentTime": current.isFinite ? max(0, current) : 0,
131
+ "duration": duration.isFinite ? max(0, duration) : 0,
132
+ "playing": player.timeControlStatus == .playing,
133
+ "isLoaded": item.status == .readyToPlay,
134
+ "isBuffering": player.timeControlStatus == .waitingToPlayAtSpecifiedRate,
135
+ "didJustFinish": didJustFinish,
136
+ "error": error,
137
+ ])
138
+ }
139
+ }
140
+
141
+ /**
142
+ * The app's audio session while a note plays. A note plays with the ringer
143
+ * switch on silent, like in messaging apps, so an app still on the default
144
+ * (silenced) category is moved to playback for the note and put back after.
145
+ * An app that chose its own category keeps it.
146
+ */
147
+ final class RepzoVoiceSession {
148
+ static let shared = RepzoVoiceSession()
149
+
150
+ private weak var owner: RepzoVoicePlayer?
151
+ private var previous: (category: AVAudioSession.Category, mode: AVAudioSession.Mode, options: AVAudioSession.CategoryOptions)?
152
+
153
+ func begin(_ player: RepzoVoicePlayer) {
154
+ owner = player
155
+ let session = AVAudioSession.sharedInstance()
156
+ // Only .soloAmbient is the system default; .ambient is a deliberate choice
157
+ // to mix with music and follow the silent switch, so it's left alone.
158
+ if previous == nil, session.category == .soloAmbient {
159
+ previous = (session.category, session.mode, session.categoryOptions)
160
+ try? session.setCategory(.playback, mode: .spokenAudio, options: [])
161
+ }
162
+ try? session.setActive(true)
163
+ }
164
+
165
+ func end(_ player: RepzoVoicePlayer) {
166
+ guard owner === player else { return }
167
+ owner = nil
168
+ guard let previous = previous else { return }
169
+ self.previous = nil
170
+ let session = AVAudioSession.sharedInstance()
171
+ // Lets music the note interrupted carry on.
172
+ try? session.setActive(false, options: .notifyOthersOnDeactivation)
173
+ try? session.setCategory(previous.category, mode: previous.mode, options: previous.options)
174
+ }
175
+ }
@@ -0,0 +1,26 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'RepzoWidgetAudio'
7
+ s.version = package['version']
8
+ s.summary = 'Plays voice messages inside the Repzo chat widget'
9
+ s.description = 'Plays voice messages inside the Repzo chat widget with the system audio player.'
10
+ s.license = package['license']
11
+ s.author = 'Repzo'
12
+ s.homepage = 'https://www.npmjs.com/package/@repzo/widget-react-native'
13
+ s.platforms = { :ios => '13.4' }
14
+ s.swift_version = '5.9'
15
+ s.source = { git: '' }
16
+ s.static_framework = true
17
+
18
+ s.dependency 'ExpoModulesCore'
19
+
20
+ s.pod_target_xcconfig = {
21
+ 'DEFINES_MODULE' => 'YES',
22
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule'
23
+ }
24
+
25
+ s.source_files = '**/*.swift'
26
+ end