Skip to content

Commit 89d2853

Browse files
committed
feat: use nitro sound/audio playback instead of rn video
1 parent c84517c commit 89d2853

File tree

5 files changed

+597
-67
lines changed

5 files changed

+597
-67
lines changed
Lines changed: 336 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,340 @@
1-
import React from 'react';
1+
import type {
2+
AVPlaybackStatusToSet,
3+
PlaybackStatus,
4+
SoundReturnType,
5+
} from 'stream-chat-react-native-core';
26

3-
import AudioVideoPlayer from './AudioVideo';
7+
let LegacyAudioRecorderPlayer;
8+
let createNitroSound;
9+
10+
try {
11+
({ createSound: createNitroSound } = require('react-native-nitro-sound'));
12+
} catch (e) {
13+
// do nothing
14+
}
15+
16+
try {
17+
LegacyAudioRecorderPlayer = require('react-native-audio-recorder-player').default;
18+
} catch (e) {
19+
// do nothing
20+
}
21+
22+
const PROGRESS_UPDATE_INTERVAL_MILLIS = 100;
23+
24+
type NativePlaybackMeta = {
25+
currentPosition?: number;
26+
duration?: number;
27+
isFinished?: boolean;
28+
};
29+
30+
type NativePlaybackEndMeta = {
31+
currentPosition: number;
32+
duration: number;
33+
};
34+
35+
type NativePlaybackInstance = {
36+
addPlayBackListener: (callback: (meta: NativePlaybackMeta) => void) => void;
37+
pausePlayer: () => Promise<string>;
38+
removePlayBackListener: () => void;
39+
resumePlayer: () => Promise<string>;
40+
seekToPlayer: (time: number) => Promise<string>;
41+
setPlaybackSpeed?: (playbackSpeed: number) => Promise<string>;
42+
setSubscriptionDuration?: (seconds: number) => void | Promise<string>;
43+
startPlayer: (uri?: string, httpHeaders?: Record<string, string>) => Promise<string>;
44+
stopPlayer: () => Promise<string>;
45+
addPlaybackEndListener?: (callback: (meta: NativePlaybackEndMeta) => void) => void;
46+
dispose?: () => void;
47+
removePlaybackEndListener?: () => void;
48+
};
49+
50+
const createPlaybackInstance = (): NativePlaybackInstance | null => {
51+
if (createNitroSound) {
52+
return createNitroSound();
53+
}
54+
55+
if (LegacyAudioRecorderPlayer) {
56+
return new LegacyAudioRecorderPlayer();
57+
}
58+
59+
return null;
60+
};
61+
62+
const createPlaybackStatus = ({
63+
didJustFinish = false,
64+
durationMillis,
65+
error = null,
66+
isLoaded,
67+
isPlaying,
68+
positionMillis,
69+
}: {
70+
didJustFinish?: boolean;
71+
durationMillis: number;
72+
error?: string | null;
73+
isLoaded: boolean;
74+
isPlaying: boolean;
75+
positionMillis: number;
76+
}): PlaybackStatus => ({
77+
currentPosition: positionMillis,
78+
didJustFinish,
79+
duration: durationMillis,
80+
durationMillis,
81+
error,
82+
isBuffering: false,
83+
isLoaded,
84+
isLooping: false,
85+
isMuted: false,
86+
isPlaying,
87+
isSeeking: false,
88+
positionMillis,
89+
shouldPlay: isPlaying,
90+
});
91+
92+
class NativeAudioSoundAdapter implements SoundReturnType {
93+
testID = 'native-audio-sound';
94+
private playbackInstance: NativePlaybackInstance | null;
95+
private sourceUri?: string;
96+
private onPlaybackStatusUpdate?: (playbackStatus: PlaybackStatus) => void;
97+
private isDisposed = false;
98+
private isLoaded = false;
99+
private isPlaying = false;
100+
private durationMillis = 0;
101+
private positionMillis = 0;
102+
private playbackRate = 1;
103+
private hasProgressListener = false;
104+
private hasPlaybackEndListener = false;
105+
106+
constructor({
107+
source,
108+
initialStatus,
109+
onPlaybackStatusUpdate,
110+
}: {
111+
source?: { uri: string };
112+
initialStatus?: Partial<AVPlaybackStatusToSet>;
113+
onPlaybackStatusUpdate?: (playbackStatus: PlaybackStatus) => void;
114+
}) {
115+
this.playbackInstance = createPlaybackInstance();
116+
this.sourceUri = source?.uri;
117+
this.onPlaybackStatusUpdate = onPlaybackStatusUpdate;
118+
this.playbackRate = initialStatus?.rate ?? 1;
119+
this.positionMillis = initialStatus?.positionMillis ?? 0;
120+
const progressUpdateIntervalMillis =
121+
initialStatus?.progressUpdateIntervalMillis ?? PROGRESS_UPDATE_INTERVAL_MILLIS;
122+
123+
this.playbackInstance?.setSubscriptionDuration?.(progressUpdateIntervalMillis / 1000);
124+
}
125+
126+
private emitPlaybackStatus({
127+
didJustFinish = false,
128+
error = null,
129+
}: {
130+
didJustFinish?: boolean;
131+
error?: string | null;
132+
} = {}) {
133+
this.onPlaybackStatusUpdate?.(
134+
createPlaybackStatus({
135+
didJustFinish,
136+
durationMillis: this.durationMillis,
137+
error,
138+
isLoaded: this.isLoaded,
139+
isPlaying: this.isPlaying,
140+
positionMillis: this.positionMillis,
141+
}),
142+
);
143+
}
144+
145+
private attachListeners() {
146+
if (!this.playbackInstance || this.hasProgressListener) {
147+
return;
148+
}
149+
150+
this.playbackInstance.addPlayBackListener(this.handlePlaybackProgress);
151+
this.hasProgressListener = true;
152+
153+
if (this.playbackInstance.addPlaybackEndListener) {
154+
this.playbackInstance.addPlaybackEndListener(this.handlePlaybackEnd);
155+
this.hasPlaybackEndListener = true;
156+
}
157+
}
158+
159+
private detachListeners() {
160+
if (!this.playbackInstance) {
161+
return;
162+
}
163+
164+
if (this.hasProgressListener) {
165+
this.playbackInstance.removePlayBackListener();
166+
this.hasProgressListener = false;
167+
}
168+
169+
if (this.hasPlaybackEndListener && this.playbackInstance.removePlaybackEndListener) {
170+
this.playbackInstance.removePlaybackEndListener();
171+
this.hasPlaybackEndListener = false;
172+
}
173+
}
174+
175+
private handlePlaybackProgress = ({ currentPosition, duration, isFinished }: NativePlaybackMeta) => {
176+
this.positionMillis = currentPosition ?? this.positionMillis;
177+
this.durationMillis = duration ?? this.durationMillis;
178+
179+
const didJustFinish =
180+
isFinished === true &&
181+
this.durationMillis > 0 &&
182+
this.positionMillis >= this.durationMillis;
183+
184+
if (didJustFinish) {
185+
this.isPlaying = false;
186+
}
187+
188+
this.emitPlaybackStatus({ didJustFinish });
189+
};
190+
191+
private handlePlaybackEnd = ({ currentPosition, duration }: NativePlaybackEndMeta) => {
192+
this.positionMillis = currentPosition ?? this.positionMillis;
193+
this.durationMillis = duration ?? this.durationMillis;
194+
this.isPlaying = false;
195+
this.emitPlaybackStatus({ didJustFinish: true });
196+
};
197+
198+
private async ensureLoaded({ shouldPlay }: { shouldPlay: boolean }) {
199+
if (!this.playbackInstance || this.isDisposed || !this.sourceUri) {
200+
return false;
201+
}
202+
203+
if (!this.isLoaded) {
204+
this.attachListeners();
205+
await this.playbackInstance.startPlayer(this.sourceUri);
206+
this.isLoaded = true;
207+
208+
if (this.playbackRate !== 1 && this.playbackInstance.setPlaybackSpeed) {
209+
await this.playbackInstance.setPlaybackSpeed(this.playbackRate);
210+
}
211+
212+
if (this.positionMillis > 0) {
213+
await this.playbackInstance.seekToPlayer(this.positionMillis);
214+
}
215+
216+
if (!shouldPlay) {
217+
await this.playbackInstance.pausePlayer();
218+
}
219+
} else if (shouldPlay) {
220+
await this.playbackInstance.resumePlayer();
221+
}
222+
223+
return true;
224+
}
225+
226+
playAsync: SoundReturnType['playAsync'] = async () => {
227+
const loaded = await this.ensureLoaded({ shouldPlay: true });
228+
if (!loaded) {
229+
return;
230+
}
231+
232+
this.isPlaying = true;
233+
this.emitPlaybackStatus();
234+
};
235+
236+
resume: SoundReturnType['resume'] = () => {
237+
void this.playAsync?.();
238+
};
239+
240+
pauseAsync: SoundReturnType['pauseAsync'] = async () => {
241+
if (!this.playbackInstance || !this.isLoaded || this.isDisposed) {
242+
return;
243+
}
244+
245+
await this.playbackInstance.pausePlayer();
246+
this.isPlaying = false;
247+
this.emitPlaybackStatus();
248+
};
249+
250+
pause: SoundReturnType['pause'] = () => {
251+
void this.pauseAsync?.();
252+
};
253+
254+
seek: SoundReturnType['seek'] = async (progress) => {
255+
const loaded = await this.ensureLoaded({ shouldPlay: false });
256+
if (!loaded || !this.playbackInstance) {
257+
return;
258+
}
259+
260+
this.positionMillis = progress * 1000;
261+
await this.playbackInstance.seekToPlayer(this.positionMillis);
262+
this.emitPlaybackStatus();
263+
};
264+
265+
setPositionAsync: SoundReturnType['setPositionAsync'] = async (millis) => {
266+
await this.seek?.(millis / 1000);
267+
};
268+
269+
setRateAsync: SoundReturnType['setRateAsync'] = async (rate) => {
270+
this.playbackRate = rate;
271+
272+
if (this.playbackInstance?.setPlaybackSpeed && this.isLoaded) {
273+
await this.playbackInstance.setPlaybackSpeed(rate);
274+
}
275+
};
276+
277+
replayAsync: SoundReturnType['replayAsync'] = async () => {
278+
await this.stopAsync?.();
279+
this.positionMillis = 0;
280+
await this.playAsync?.();
281+
};
282+
283+
stopAsync: SoundReturnType['stopAsync'] = async () => {
284+
if (!this.playbackInstance || !this.isLoaded || this.isDisposed) {
285+
return;
286+
}
287+
288+
await this.playbackInstance.stopPlayer();
289+
this.isLoaded = false;
290+
this.isPlaying = false;
291+
this.positionMillis = 0;
292+
this.emitPlaybackStatus();
293+
};
294+
295+
unloadAsync: SoundReturnType['unloadAsync'] = async () => {
296+
if (this.isDisposed) {
297+
return;
298+
}
299+
300+
try {
301+
if (this.isLoaded && this.playbackInstance) {
302+
await this.playbackInstance.stopPlayer();
303+
}
304+
} catch {
305+
// Best effort cleanup.
306+
}
307+
308+
this.detachListeners();
309+
this.playbackInstance?.dispose?.();
310+
this.isLoaded = false;
311+
this.isPlaying = false;
312+
this.isDisposed = true;
313+
};
314+
}
315+
316+
const initializeSound =
317+
createNitroSound || LegacyAudioRecorderPlayer
318+
? async (
319+
source?: { uri: string },
320+
initialStatus?: Partial<AVPlaybackStatusToSet>,
321+
onPlaybackStatusUpdate?: (playbackStatus: PlaybackStatus) => void,
322+
) => {
323+
if (!source?.uri) {
324+
return null;
325+
}
326+
327+
const sound = new NativeAudioSoundAdapter({
328+
initialStatus,
329+
onPlaybackStatusUpdate,
330+
source,
331+
});
332+
333+
return sound;
334+
}
335+
: null;
4336

5337
export const Sound = {
6-
initializeSound: null,
7-
// eslint-disable-next-line react/display-name
8-
Player: AudioVideoPlayer
9-
? ({
10-
onBuffer,
11-
onEnd,
12-
onLoad,
13-
onLoadStart,
14-
onPlaybackStateChanged,
15-
onProgress,
16-
onSeek,
17-
paused,
18-
rate,
19-
soundRef,
20-
style,
21-
uri,
22-
}) => (
23-
<AudioVideoPlayer
24-
audioOnly={true}
25-
ignoreSilentSwitch={'ignore'}
26-
onBuffer={onBuffer}
27-
onEnd={onEnd}
28-
onError={(error: Error) => {
29-
console.log(error);
30-
}}
31-
onLoad={onLoad}
32-
onLoadStart={onLoadStart}
33-
onPlaybackStateChanged={onPlaybackStateChanged}
34-
onProgress={onProgress}
35-
onSeek={onSeek}
36-
paused={paused}
37-
rate={rate}
38-
ref={soundRef}
39-
progressUpdateInterval={100}
40-
source={{
41-
uri,
42-
}}
43-
style={style}
44-
/>
45-
)
46-
: null,
338+
initializeSound,
339+
Player: null,
47340
};

package/src/components/Attachment/Audio/AudioAttachment.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ export const AudioAttachment = (props: AudioAttachmentProps) => {
131131

132132
/** This is for Native CLI Apps */
133133
const handleLoad = (payload: VideoPayloadData) => {
134-
// If the attachment is a voice recording, we rely on the duration from the attachment as the one from the react-native-video is incorrect.
134+
// Voice recordings already carry the canonical duration in the attachment payload.
135135
if (isVoiceRecording) {
136136
return;
137137
}

0 commit comments

Comments
 (0)