Skip to content

Commit

Permalink
feat: implement recorder and player service for expo
Browse files Browse the repository at this point in the history
  • Loading branch information
bang9 committed Nov 3, 2023
1 parent a2a0680 commit 851ec0e
Show file tree
Hide file tree
Showing 2 changed files with 292 additions and 0 deletions.
145 changes: 145 additions & 0 deletions packages/uikit-react-native/src/platform/createPlayerService.expo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import type * as ExpoAV from 'expo-av';

import { matchesOneOf } from '@sendbird/uikit-utils';

import expoPermissionGranted from '../utils/expoPermissionGranted';
import type { PlayerServiceInterface, Unsubscribe } from './types';

type Modules = {
avModule: typeof ExpoAV;
};
type PlaybackListener = Parameters<PlayerServiceInterface['addPlaybackListener']>[number];
type StateListener = Parameters<PlayerServiceInterface['addStateListener']>[number];
const createNativePlayerService = ({ avModule }: Modules): PlayerServiceInterface => {
const sound = new avModule.Audio.Sound();

class VoicePlayer implements PlayerServiceInterface {
uri?: string;
state: PlayerServiceInterface['state'] = 'idle';

private readonly playbackSubscribers = new Set<PlaybackListener>();
private readonly stateSubscribers = new Set<StateListener>();

constructor() {
sound.setProgressUpdateIntervalAsync(100);
}

private setState = (state: PlayerServiceInterface['state']) => {
this.state = state;
this.stateSubscribers.forEach((callback) => {
callback(state);
});
};

private setListener = () => {
sound.setOnPlaybackStatusUpdate((status) => {
if (status.isLoaded) {
if (status.didJustFinish) this.stop();
if (status.isPlaying) {
this.playbackSubscribers.forEach((callback) => {
callback({
currentTime: status.positionMillis,
duration: status.durationMillis ?? 0,
stopped: status.didJustFinish,
});
});
}
}
});
};

private removeListener = () => {
sound.setOnPlaybackStatusUpdate(null);
};

public requestPermission = async (): Promise<boolean> => {
const status = await avModule.Audio.getPermissionsAsync();
if (expoPermissionGranted(status)) {
return true;
} else {
const status = await avModule.Audio.requestPermissionsAsync();
return expoPermissionGranted(status);
}
};

public addPlaybackListener = (callback: PlaybackListener): Unsubscribe => {
this.playbackSubscribers.add(callback);
return () => {
this.playbackSubscribers.delete(callback);
};
};

public addStateListener = (callback: (state: PlayerServiceInterface['state']) => void): Unsubscribe => {
this.stateSubscribers.add(callback);
return () => {
this.stateSubscribers.delete(callback);
};
};

private prepare = async (uri: string) => {
this.setState('preparing');
await sound.loadAsync({ uri }, { shouldPlay: false }, true);
this.uri = uri;
};

public play = async (uri: string): Promise<void> => {
if (matchesOneOf(this.state, ['idle', 'stopped'])) {
try {
await this.prepare(uri);
this.setListener();
await sound.playAsync();
this.setState('playing');
} catch (e) {
this.setState('idle');
this.uri = undefined;
this.removeListener();
throw e;
}
} else if (matchesOneOf(this.state, ['paused']) && this.uri === uri) {
try {
this.setListener();
await sound.replayAsync();
this.setState('playing');
} catch (e) {
this.removeListener();
throw e;
}
}
};

public pause = async (): Promise<void> => {
if (matchesOneOf(this.state, ['playing'])) {
await sound.pauseAsync();
this.removeListener();
this.setState('paused');
}
};

public stop = async (): Promise<void> => {
if (matchesOneOf(this.state, ['preparing', 'playing', 'paused'])) {
await sound.stopAsync();
await sound.unloadAsync();
this.removeListener();
this.setState('stopped');
}
};

public reset = async (): Promise<void> => {
await this.stop();
this.setState('idle');
this.uri = undefined;
this.playbackSubscribers.clear();
this.stateSubscribers.clear();
};

public seek = async (time: number): Promise<void> => {
if (matchesOneOf(this.state, ['playing', 'paused'])) {
await sound.playFromPositionAsync(time);
}
};
}

return new VoicePlayer();
};

export default createNativePlayerService;
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import * as ExpoAV from 'expo-av';
import type { RecordingOptions } from 'expo-av/build/Audio/Recording.types';

import { matchesOneOf } from '@sendbird/uikit-utils';

import expoPermissionGranted from '../utils/expoPermissionGranted';
import type { RecorderServiceInterface, Unsubscribe } from './types';

type RecordingListener = Parameters<RecorderServiceInterface['addRecordingListener']>[number];
type StateListener = Parameters<RecorderServiceInterface['addStateListener']>[number];
type Modules = {
avModule: typeof ExpoAV;
};
const createNativeRecorderService = ({ avModule }: Modules): RecorderServiceInterface => {
const recorder = new avModule.Audio.Recording();

class VoiceRecorder implements RecorderServiceInterface {
public state: RecorderServiceInterface['state'] = 'idle';
public options: RecorderServiceInterface['options'] = {
minDuration: 1000,
maxDuration: 60000,
extension: 'm4a',
};

// NOTE: In Android, even when startRecorder() is awaited, if stop() is executed immediately afterward, an error occurs
// private _recordStartedAt = 0;
// private _getRecorderStopSafeBuffer = () => {
// const minWaitingTime = 500;
// const elapsedTime = Date.now() - this._recordStartedAt;
// if (elapsedTime > minWaitingTime) return 0;
// else return minWaitingTime - elapsedTime;
// };

private readonly recordingSubscribers = new Set<RecordingListener>();
private readonly stateSubscribers = new Set<StateListener>();
private readonly audioSettings = {
sampleRate: 11025,
bitRate: 12000,
numberOfChannels: 1,
// encoding: mpeg4_aac
};
private readonly audioOptions: RecordingOptions = {
android: {
...this.audioSettings,
extension: this.options.extension,
audioEncoder: avModule.Audio.AndroidAudioEncoder.AAC,
outputFormat: avModule.Audio.AndroidOutputFormat.MPEG_4,
},
ios: {
...this.audioSettings,
extension: this.options.extension,
outputFormat: avModule.Audio.IOSOutputFormat.MPEG4AAC,
audioQuality: avModule.Audio.IOSAudioQuality.HIGH,
},
web: {},
};

constructor() {
recorder.setProgressUpdateInterval(100);
recorder.setOnRecordingStatusUpdate((status) => {
const completed = status.durationMillis >= this.options.maxDuration;
if (completed) this.stop();
if (status.isRecording) {
this.recordingSubscribers.forEach((callback) => {
callback({ currentTime: status.durationMillis, completed: completed });
});
}
});
}

private prepare = async () => {
this.setState('preparing');
await recorder.prepareToRecordAsync(this.audioOptions);
};

private setState = (state: RecorderServiceInterface['state']) => {
this.state = state;
this.stateSubscribers.forEach((callback) => {
callback(state);
});
};

public requestPermission = async (): Promise<boolean> => {
const status = await avModule.Audio.getPermissionsAsync();
if (expoPermissionGranted(status)) {
return true;
} else {
const status = await avModule.Audio.requestPermissionsAsync();
return expoPermissionGranted(status);
}
};

public addRecordingListener = (callback: RecordingListener): Unsubscribe => {
this.recordingSubscribers.add(callback);
return () => {
this.recordingSubscribers.delete(callback);
};
};

public addStateListener = (callback: StateListener): Unsubscribe => {
this.stateSubscribers.add(callback);
return () => {
this.stateSubscribers.delete(callback);
};
};

public record = async (): Promise<void> => {
if (matchesOneOf(this.state, ['idle', 'completed'])) {
try {
await this.prepare();
await recorder.startAsync();

// if (Platform.OS === 'android') {
// this._recordStartedAt = Date.now();
// }

this.setState('recording');
} catch (e) {
this.setState('idle');
throw e;
}
}
};

public stop = async (): Promise<void> => {
if (matchesOneOf(this.state, ['recording'])) {
// if (Platform.OS === 'android') {
// const buffer = this._getRecorderStopSafeBuffer();
// if (buffer > 0) await sleep(buffer);
// }

await recorder.stopAndUnloadAsync();
this.setState('completed');
}
};

public reset = async (): Promise<void> => {
await this.stop();
this.setState('idle');
this.recordingSubscribers.clear();
};
}

return new VoiceRecorder();
};

export default createNativeRecorderService;

0 comments on commit 851ec0e

Please sign in to comment.