@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.
- package/CHANGELOG.md +41 -0
- package/README.md +35 -29
- package/android/build.gradle +22 -0
- package/android/src/main/java/expo/modules/repzowidget/RepzoVoicePlayer.kt +222 -0
- package/android/src/main/java/expo/modules/repzowidget/RepzoWidgetAudioModule.kt +68 -0
- package/expo-module.config.json +9 -0
- package/ios/RepzoVoicePlayer.swift +175 -0
- package/ios/RepzoWidgetAudio.podspec +26 -0
- package/ios/RepzoWidgetAudioModule.swift +65 -0
- package/lib/commonjs/audio/native-player.js +97 -0
- package/lib/commonjs/audio/native-player.js.map +1 -0
- package/lib/commonjs/audio/voice.js +34 -7
- package/lib/commonjs/audio/voice.js.map +1 -1
- package/lib/commonjs/state/widget-store.js +23 -1
- package/lib/commonjs/state/widget-store.js.map +1 -1
- package/lib/commonjs/ui/WidgetProvider.js +1 -3
- package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
- package/lib/commonjs/ui/components/ImageViewer.js +17 -35
- package/lib/commonjs/ui/components/ImageViewer.js.map +1 -1
- package/lib/commonjs/ui/components/MessagesSkeleton.js +126 -0
- package/lib/commonjs/ui/components/MessagesSkeleton.js.map +1 -0
- package/lib/commonjs/ui/components/VoiceNote.js +8 -9
- package/lib/commonjs/ui/components/VoiceNote.js.map +1 -1
- package/lib/commonjs/ui/components/ZoomableImage.js +299 -0
- package/lib/commonjs/ui/components/ZoomableImage.js.map +1 -0
- package/lib/commonjs/ui/screens/chat/ChatView.js +6 -4
- package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js +5 -7
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/module/audio/native-player.js +93 -0
- package/lib/module/audio/native-player.js.map +1 -0
- package/lib/module/audio/voice.js +32 -6
- package/lib/module/audio/voice.js.map +1 -1
- package/lib/module/state/widget-store.js +23 -1
- package/lib/module/state/widget-store.js.map +1 -1
- package/lib/module/ui/WidgetProvider.js +1 -3
- package/lib/module/ui/WidgetProvider.js.map +1 -1
- package/lib/module/ui/components/ImageViewer.js +18 -36
- package/lib/module/ui/components/ImageViewer.js.map +1 -1
- package/lib/module/ui/components/MessagesSkeleton.js +124 -0
- package/lib/module/ui/components/MessagesSkeleton.js.map +1 -0
- package/lib/module/ui/components/VoiceNote.js +9 -10
- package/lib/module/ui/components/VoiceNote.js.map +1 -1
- package/lib/module/ui/components/ZoomableImage.js +291 -0
- package/lib/module/ui/components/ZoomableImage.js.map +1 -0
- package/lib/module/ui/screens/chat/ChatView.js +6 -4
- package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.styles.js +5 -7
- package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/typescript/commonjs/audio/native-player.d.ts +36 -0
- package/lib/typescript/commonjs/audio/native-player.d.ts.map +1 -0
- package/lib/typescript/commonjs/audio/voice.d.ts +17 -4
- package/lib/typescript/commonjs/audio/voice.d.ts.map +1 -1
- package/lib/typescript/commonjs/state/widget-store.d.ts +11 -0
- package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts +2 -2
- package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/MessagesSkeleton.d.ts +10 -0
- package/lib/typescript/commonjs/ui/components/MessagesSkeleton.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts +2 -2
- package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts +45 -0
- package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +4 -7
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
- package/lib/typescript/module/audio/native-player.d.ts +36 -0
- package/lib/typescript/module/audio/native-player.d.ts.map +1 -0
- package/lib/typescript/module/audio/voice.d.ts +17 -4
- package/lib/typescript/module/audio/voice.d.ts.map +1 -1
- package/lib/typescript/module/state/widget-store.d.ts +11 -0
- package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/ImageViewer.d.ts +2 -2
- package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/MessagesSkeleton.d.ts +10 -0
- package/lib/typescript/module/ui/components/MessagesSkeleton.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/VoiceNote.d.ts +2 -2
- package/lib/typescript/module/ui/components/VoiceNote.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/ZoomableImage.d.ts +45 -0
- package/lib/typescript/module/ui/components/ZoomableImage.d.ts.map +1 -0
- package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +4 -7
- package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
- package/package.json +10 -3
- package/react-native.config.js +12 -0
- package/src/audio/native-player.ts +106 -0
- package/src/audio/voice.ts +30 -6
- package/src/state/widget-store.ts +24 -1
- package/src/ui/WidgetProvider.tsx +1 -3
- package/src/ui/components/ImageViewer.tsx +11 -30
- package/src/ui/components/MessagesSkeleton.tsx +92 -0
- package/src/ui/components/VoiceNote.tsx +6 -14
- package/src/ui/components/ZoomableImage.tsx +250 -0
- package/src/ui/screens/chat/ChatView.styles.ts +5 -7
- 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
|

|
|
8
8
|
|
|
9
9
|
The Repzo chat widget for React Native — an Intercom-style messenger that drops
|
|
10
|
-
into any app. Pure JavaScript/TypeScript
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
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+*) —
|
|
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
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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,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
|