Skip to content

Commit 83dd28b

Browse files
committed
feat: sync audio centrally for async audio recording preview
1 parent 1f97dc4 commit 83dd28b

File tree

6 files changed

+146
-39
lines changed

6 files changed

+146
-39
lines changed

package/native-package/src/optionalDependencies/Audio.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,9 @@ class _Audio {
131131
resumePlayer = async () => {
132132
await audioRecorderPlayer.resumePlayer();
133133
};
134+
seekToPlayer = async (positionInMillis: number) => {
135+
await audioRecorderPlayer.seekToPlayer(positionInMillis);
136+
};
134137
startPlayer = async (uri, _, onPlaybackStatusUpdate) => {
135138
try {
136139
const playback = await audioRecorderPlayer.startPlayer(uri);

package/src/components/MessageInput/MessageInput.tsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -311,11 +311,7 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => {
311311
const {
312312
deleteVoiceRecording,
313313
micLocked,
314-
onVoicePlayerPlayPause,
315-
paused,
316314
permissionsGranted,
317-
position,
318-
progress,
319315
recording,
320316
recordingDuration,
321317
recordingStatus,
@@ -449,10 +445,12 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => {
449445
/>
450446
{recordingStatus === 'stopped' ? (
451447
<AudioRecordingPreview
452-
onVoicePlayerPlayPause={onVoicePlayerPlayPause}
453-
paused={paused}
454-
position={position}
455-
progress={progress}
448+
recordingDuration={recordingDuration}
449+
uri={
450+
typeof recording !== 'string'
451+
? (recording?.getURI() as string)
452+
: (recording as string)
453+
}
456454
waveformData={waveformData}
457455
/>
458456
) : micLocked ? (

package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,63 @@
1-
import React from 'react';
1+
import React, { useEffect, useMemo } from 'react';
22
import { Pressable, StyleSheet, Text, View } from 'react-native';
33

44
import dayjs from 'dayjs';
55

66
import { useTheme } from '../../../../contexts/themeContext/ThemeContext';
7+
import { useAudioPlayerControl } from '../../../../hooks/useAudioPlayerControl';
8+
import { useStateStore } from '../../../../hooks/useStateStore';
79
import { Pause, Play } from '../../../../icons';
810

11+
import { NativeHandlers } from '../../../../native';
12+
import { AudioPlayerState } from '../../../../state-store/audio-player';
913
import { WaveProgressBar } from '../../../ProgressControl/WaveProgressBar';
1014

15+
const ONE_SECOND_IN_MILLISECONDS = 1000;
16+
const ONE_HOUR_IN_MILLISECONDS = 3600 * 1000;
17+
1118
export type AudioRecordingPreviewProps = {
12-
/**
13-
* Boolean used to show the paused state of the player.
14-
*/
15-
paused: boolean;
16-
/**
17-
* Number used to show the current position of the audio being played.
18-
*/
19-
position: number;
20-
/**
21-
* Number used to show the percentage of progress of the audio being played. It should be in 0-1 range.
22-
*/
23-
progress: number;
19+
recordingDuration: number;
20+
uri: string;
2421
/**
2522
* The waveform data to be presented to show the audio levels.
2623
*/
2724
waveformData: number[];
28-
/**
29-
* Function to play or pause the audio player.
30-
*/
31-
onVoicePlayerPlayPause?: () => Promise<void>;
3225
};
3326

27+
const audioPlayerSelector = (state: AudioPlayerState) => ({
28+
duration: state.duration,
29+
isPlaying: state.isPlaying,
30+
position: state.position,
31+
progress: state.progress,
32+
});
33+
3434
/**
3535
* Component displayed when the audio is recorded and can be previewed.
3636
*/
3737
export const AudioRecordingPreview = (props: AudioRecordingPreviewProps) => {
38-
const { onVoicePlayerPlayPause, paused, position, progress, waveformData } = props;
38+
const { recordingDuration, uri, waveformData } = props;
39+
40+
const { audioPlayer, toggleAudio } = useAudioPlayerControl({
41+
duration: recordingDuration / ONE_SECOND_IN_MILLISECONDS,
42+
mimeType: 'audio/aac',
43+
// This is a temporary flag to manage audio player for voice recording in preview as the one in message list uses react-native-video.
44+
previewVoiceRecording: !(NativeHandlers.SDK === 'stream-chat-expo'),
45+
type: 'voiceRecording',
46+
uri,
47+
});
48+
49+
const { duration, isPlaying, position, progress } = useStateStore(
50+
audioPlayer.state,
51+
audioPlayerSelector,
52+
);
53+
54+
// When a audio attachment in preview is removed, we need to remove the player from the pool
55+
useEffect(
56+
() => () => {
57+
audioPlayer.onRemove();
58+
},
59+
[audioPlayer],
60+
);
3961

4062
const {
4163
theme: {
@@ -53,19 +75,33 @@ export const AudioRecordingPreview = (props: AudioRecordingPreviewProps) => {
5375
},
5476
} = useTheme();
5577

78+
const handlePlayPause = () => {
79+
toggleAudio(audioPlayer.id);
80+
};
81+
82+
const progressDuration = useMemo(
83+
() =>
84+
position
85+
? position / ONE_HOUR_IN_MILLISECONDS >= 1
86+
? dayjs.duration(position, 'milliseconds').format('HH:mm:ss')
87+
: dayjs.duration(position, 'milliseconds').format('mm:ss')
88+
: dayjs.duration(duration, 'milliseconds').format('mm:ss'),
89+
[duration, position],
90+
);
91+
5692
return (
5793
<View style={[styles.container, container]}>
5894
<View style={[styles.infoContainer, infoContainer]}>
59-
<Pressable onPress={onVoicePlayerPlayPause}>
60-
{paused ? (
95+
<Pressable onPress={handlePlayPause}>
96+
{!isPlaying ? (
6197
<Play fill={accent_blue} height={32} width={32} {...playIcon} />
6298
) : (
6399
<Pause fill={accent_blue} height={32} width={32} {...pauseIcon} />
64100
)}
65101
</Pressable>
66102
{/* `durationMillis` is for Expo apps, `currentPosition` is for Native CLI apps. */}
67103
<Text style={[styles.currentTime, { color: grey_dark }, currentTime]}>
68-
{dayjs.duration(position).format('mm:ss')}
104+
{progressDuration}
69105
</Text>
70106
</View>
71107
<View style={[styles.progressBar, progressBar]}>

package/src/hooks/useAudioPlayerControl.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export const useAudioPlayerControl = ({
3333
duration,
3434
mimeType,
3535
playbackRates,
36+
previewVoiceRecording,
3637
requester = '',
3738
type,
3839
uri,
@@ -47,10 +48,11 @@ export const useAudioPlayerControl = ({
4748
id,
4849
mimeType: mimeType ?? '',
4950
playbackRates,
51+
previewVoiceRecording,
5052
type: type ?? 'audio',
5153
uri: uri ?? '',
5254
}),
53-
[audioPlayerPool, duration, id, mimeType, playbackRates, type, uri],
55+
[audioPlayerPool, duration, id, mimeType, playbackRates, previewVoiceRecording, type, uri],
5456
);
5557

5658
return {

package/src/native.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ export type AudioType = {
227227
stopRecording: () => Promise<void>;
228228
pausePlayer?: () => Promise<void>;
229229
resumePlayer?: () => Promise<void>;
230+
seekToPlayer?: (positionInMillis: number) => Promise<void>;
230231
startPlayer?: (
231232
uri?: AudioRecordingReturnType,
232233
initialStatus?: Partial<AVPlaybackStatusToSet>,

package/src/state-store/audio-player.ts

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const INITIAL_STATE: AudioPlayerState = {
3838

3939
export type AudioPlayerOptions = AudioDescriptor & {
4040
playbackRates?: number[];
41+
previewVoiceRecording?: boolean;
4142
};
4243

4344
export class AudioPlayer {
@@ -46,11 +47,14 @@ export class AudioPlayer {
4647
private _id: string;
4748
private type: 'voiceRecording' | 'audio';
4849
private isExpoCLI: boolean;
50+
// This is a temporary flag to manage audio player for voice recording in preview as the one in message list uses react-native-video.
51+
private previewVoiceRecording: boolean;
4952

5053
constructor(options: AudioPlayerOptions) {
5154
this.isExpoCLI = NativeHandlers.SDK === 'stream-chat-expo';
5255
this._id = options.id;
5356
this.type = options.type;
57+
this.previewVoiceRecording = options.previewVoiceRecording ?? false;
5458
const playbackRates = options.playbackRates ?? DEFAULT_PLAYBACK_RATES;
5559
this.state = new StateStore<AudioPlayerState>({
5660
...INITIAL_STATE,
@@ -68,6 +72,19 @@ export class AudioPlayer {
6872
this.playerRef = playerRef;
6973
return;
7074
}
75+
if (this.previewVoiceRecording) {
76+
if (NativeHandlers.Audio?.startPlayer) {
77+
await NativeHandlers.Audio.startPlayer(
78+
uri,
79+
{},
80+
this.onVoiceRecordingPreviewPlaybackStatusUpdate,
81+
);
82+
if (NativeHandlers.Audio?.pausePlayer) {
83+
await NativeHandlers.Audio.pausePlayer();
84+
}
85+
}
86+
return;
87+
}
7188
if (!this.isExpoCLI || !uri) {
7289
return;
7390
}
@@ -80,6 +97,15 @@ export class AudioPlayer {
8097
}
8198
};
8299

100+
onVoiceRecordingPreviewPlaybackStatusUpdate = async (playbackStatus: PlaybackStatus) => {
101+
const currentProgress = playbackStatus.currentPosition / playbackStatus.duration;
102+
if (currentProgress === 1) {
103+
await this.stop();
104+
} else {
105+
this.progress = currentProgress;
106+
}
107+
};
108+
83109
// This should be a arrow function to avoid binding the function to the instance
84110
onPlaybackStatusUpdate = async (playbackStatus: PlaybackStatus) => {
85111
if (!playbackStatus.isLoaded) {
@@ -92,13 +118,13 @@ export class AudioPlayer {
92118
// Update your UI for the loaded state
93119
// This is done for Expo CLI where we don't get file duration from file picker
94120

95-
// The duration given by the expo-av is not same as the one of the voice recording, so we take the actual duration for voice recording.
96121
if (this.type !== 'voiceRecording') {
97122
this.duration = durationMillis;
98123
}
99124

100125
// Update the position of the audio player when it is playing
101126
if (playbackStatus.isPlaying) {
127+
// The duration given by the expo-av is not same as the one of the voice recording, so we take the actual duration for voice recording.
102128
if (this.type === 'voiceRecording') {
103129
if (positionMillis <= this.duration) {
104130
this.position = positionMillis;
@@ -108,12 +134,6 @@ export class AudioPlayer {
108134
this.position = positionMillis;
109135
}
110136
}
111-
} else {
112-
// Update your UI for the paused state
113-
}
114-
115-
if (playbackStatus.isBuffering) {
116-
// Update your UI for the buffering state
117137
}
118138

119139
// Update the UI when the audio is finished playing
@@ -199,9 +219,24 @@ export class AudioPlayer {
199219
}
200220

201221
_playInternal() {
202-
if (this.isPlaying || !this.playerRef) {
222+
if (this.isPlaying) {
203223
return;
204224
}
225+
226+
if (this.previewVoiceRecording) {
227+
if (NativeHandlers.Audio?.resumePlayer) {
228+
NativeHandlers.Audio.resumePlayer();
229+
}
230+
this.state.partialNext({
231+
isPlaying: true,
232+
});
233+
return;
234+
}
235+
236+
if (!this.playerRef) {
237+
return;
238+
}
239+
205240
if (this.isExpoCLI) {
206241
if (this.playerRef?.playAsync) {
207242
this.playerRef.playAsync();
@@ -217,9 +252,23 @@ export class AudioPlayer {
217252
}
218253

219254
_pauseInternal() {
220-
if (!this.isPlaying || !this.playerRef) {
255+
if (!this.isPlaying) {
221256
return;
222257
}
258+
if (this.previewVoiceRecording) {
259+
if (NativeHandlers.Audio?.pausePlayer) {
260+
NativeHandlers.Audio.pausePlayer();
261+
}
262+
this.state.partialNext({
263+
isPlaying: false,
264+
});
265+
return;
266+
}
267+
268+
if (!this.playerRef) {
269+
return;
270+
}
271+
223272
if (this.isExpoCLI) {
224273
if (this.playerRef?.pauseAsync) {
225274
this.playerRef.pauseAsync();
@@ -243,6 +292,13 @@ export class AudioPlayer {
243292
}
244293

245294
async seek(positionInSeconds: number) {
295+
if (this.previewVoiceRecording) {
296+
this.position = positionInSeconds;
297+
if (NativeHandlers.Audio?.seekToPlayer) {
298+
NativeHandlers.Audio.seekToPlayer(positionInSeconds * 1000);
299+
}
300+
return;
301+
}
246302
if (!this.playerRef) {
247303
return;
248304
}
@@ -272,6 +328,17 @@ export class AudioPlayer {
272328
}
273329

274330
onRemove() {
331+
if (this.previewVoiceRecording) {
332+
if (NativeHandlers.Audio?.stopPlayer) {
333+
NativeHandlers.Audio.stopPlayer();
334+
}
335+
this.state.partialNext({
336+
...INITIAL_STATE,
337+
currentPlaybackRate: this.playbackRates[0],
338+
playbackRates: DEFAULT_PLAYBACK_RATES,
339+
});
340+
return;
341+
}
275342
if (this.isExpoCLI) {
276343
if (this.playerRef?.stopAsync && this.playerRef.unloadAsync) {
277344
this.playerRef.stopAsync();

0 commit comments

Comments
 (0)