-
Notifications
You must be signed in to change notification settings - Fork 44
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: implement recorder and player service for expo
- Loading branch information
Showing
2 changed files
with
292 additions
and
0 deletions.
There are no files selected for viewing
145 changes: 145 additions & 0 deletions
145
packages/uikit-react-native/src/platform/createPlayerService.expo.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
147 changes: 147 additions & 0 deletions
147
packages/uikit-react-native/src/platform/createRecorderService.expo.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |