diff --git a/package/expo-package/src/optionalDependencies/Sound.ts b/package/expo-package/src/optionalDependencies/Sound.ts index 96015ac5c..f7a73b830 100644 --- a/package/expo-package/src/optionalDependencies/Sound.ts +++ b/package/expo-package/src/optionalDependencies/Sound.ts @@ -99,6 +99,8 @@ class ExpoAudioSoundAdapter { // eslint-disable-next-line require-await loadAsync = async (initialStatus) => { + // We have to subscribe as early as possible so that we know the initial status(durarion, etc.) of the audio. + this.subscribeStatusEventListener(); this.initialShouldCorrectPitch = initialStatus.shouldCorrectPitch; this.initialPitchCorrectionQuality = initialStatus.pitchCorrectionQuality; }; @@ -136,8 +138,7 @@ class ExpoAudioSoundAdapter { }; // eslint-disable-next-line require-await - setPositionAsync: SoundReturnType['setPositionAsync'] = async (milliseconds) => { - const seconds = milliseconds / 1000; + setPositionAsync: SoundReturnType['setPositionAsync'] = async (seconds) => { this.player.seekTo(seconds); }; diff --git a/package/native-package/src/optionalDependencies/Audio.ts b/package/native-package/src/optionalDependencies/Audio.ts index 8db7d3fa3..8df64a62f 100644 --- a/package/native-package/src/optionalDependencies/Audio.ts +++ b/package/native-package/src/optionalDependencies/Audio.ts @@ -131,6 +131,9 @@ class _Audio { resumePlayer = async () => { await audioRecorderPlayer.resumePlayer(); }; + seekToPlayer = async (positionInMillis: number) => { + await audioRecorderPlayer.seekToPlayer(positionInMillis); + }; startPlayer = async (uri, _, onPlaybackStatusUpdate) => { try { const playback = await audioRecorderPlayer.startPlayer(uri); diff --git a/package/src/components/Attachment/AudioAttachment.tsx b/package/src/components/Attachment/AudioAttachment.tsx index 59681bb41..016c78705 100644 --- a/package/src/components/Attachment/AudioAttachment.tsx +++ b/package/src/components/Attachment/AudioAttachment.tsx @@ -1,254 +1,179 @@ -import React, { RefObject, useEffect, useMemo, useState } from 'react'; +import React, { RefObject, useEffect, useMemo } from 'react'; import { I18nManager, Pressable, StyleSheet, Text, View } from 'react-native'; import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; -import { AudioAttachment as StreamAudioAttachment } from 'stream-chat'; +import { + isVoiceRecordingAttachment, + LocalMessage, + AudioAttachment as StreamAudioAttachment, + VoiceRecordingAttachment as StreamVoiceRecordingAttachment, +} from 'stream-chat'; import { useTheme } from '../../contexts'; -import { useAudioPlayer } from '../../hooks/useAudioPlayer'; +import { useStateStore } from '../../hooks'; +import { useAudioPlayerControl } from '../../hooks/useAudioPlayerControl'; import { Audio, Pause, Play } from '../../icons'; import { NativeHandlers, - PlaybackStatus, SoundReturnType, VideoPayloadData, VideoProgressData, VideoSeekResponse, } from '../../native'; -import { AudioConfig, FileTypes } from '../../types/types'; +import { AudioPlayerState } from '../../state-store/audio-player'; +import { AudioConfig } from '../../types/types'; import { getTrimmedAttachmentTitle } from '../../utils/getTrimmedAttachmentTitle'; import { ProgressControl } from '../ProgressControl/ProgressControl'; import { WaveProgressBar } from '../ProgressControl/WaveProgressBar'; +const ONE_HOUR_IN_MILLISECONDS = 3600 * 1000; +const ONE_SECOND_IN_MILLISECONDS = 1000; + dayjs.extend(duration); export type AudioAttachmentType = AudioConfig & - Pick & { + Pick< + StreamAudioAttachment | StreamVoiceRecordingAttachment, + 'waveform_data' | 'asset_url' | 'title' | 'mime_type' + > & { id: string; type: 'audio' | 'voiceRecording'; }; export type AudioAttachmentProps = { item: AudioAttachmentType; - onLoad: (index: string, duration: number) => void; - onPlayPause: (index: string, pausedStatus?: boolean) => void; - onProgress: (index: string, progress: number) => void; + message?: LocalMessage; titleMaxLength?: number; hideProgressBar?: boolean; + /** + * If true, the speed settings button will be shown. + */ showSpeedSettings?: boolean; testID?: string; + /** + * If true, the audio attachment is in preview mode in the message input. + */ + isPreview?: boolean; + /** + * Callback to be called when the audio is loaded + * @deprecated This is deprecated and will be removed in the future. + */ + onLoad?: (index: string, duration: number) => void; + /** + * Callback to be called when the audio is played or paused + * @deprecated This is deprecated and will be removed in the future. + */ + onPlayPause?: (index: string, pausedStatus?: boolean) => void; + /** + * Callback to be called when the audio progresses + * @deprecated This is deprecated and will be removed in the future. + */ + onProgress?: (index: string, progress: number) => void; }; +const audioPlayerSelector = (state: AudioPlayerState) => ({ + currentPlaybackRate: state.currentPlaybackRate, + duration: state.duration, + isPlaying: state.isPlaying, + position: state.position, + progress: state.progress, +}); + /** * AudioAttachment * UI Component to preview the audio files */ export const AudioAttachment = (props: AudioAttachmentProps) => { - const [currentSpeed, setCurrentSpeed] = useState(1.0); - const [audioFinished, setAudioFinished] = useState(false); const soundRef = React.useRef(null); + const { hideProgressBar = false, item, - onLoad, - onPlayPause, - onProgress, + message, showSpeedSettings = false, testID, titleMaxLength, + isPreview = false, } = props; - const { changeAudioSpeed, pauseAudio, playAudio, seekAudio } = useAudioPlayer({ soundRef }); - const isExpoCLI = NativeHandlers.SDK === 'stream-chat-expo'; - const isVoiceRecording = item.type === FileTypes.VoiceRecording; + const isVoiceRecording = isVoiceRecordingAttachment(item); + + const audioPlayer = useAudioPlayerControl({ + duration: item.duration ?? 0, + mimeType: item.mime_type ?? '', + requester: isPreview + ? 'preview' + : message?.id && `${message?.parent_id ?? message?.id}${message?.id}`, + type: isVoiceRecording ? 'voiceRecording' : 'audio', + uri: item.asset_url ?? '', + }); + const { duration, isPlaying, position, progress, currentPlaybackRate } = useStateStore( + audioPlayer.state, + audioPlayerSelector, + ); + + // Initialize the player for native cli apps + useEffect(() => { + if (soundRef.current) { + audioPlayer.initPlayer({ playerRef: soundRef.current }); + } + }, [audioPlayer]); + + // When a audio attachment in preview is removed, we need to remove the player from the pool + useEffect( + () => () => { + if (isPreview) { + audioPlayer.onRemove(); + } + }, + [audioPlayer, isPreview], + ); /** This is for Native CLI Apps */ const handleLoad = (payload: VideoPayloadData) => { - // The duration given by the rn-video is not same as the one of the voice recording, so we take the actual duration for voice recording. - if (isVoiceRecording && item.duration) { - onLoad(item.id, item.duration); - } else { - onLoad(item.id, item.duration || payload.duration); + // 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. + if (isVoiceRecording) { + return; } + audioPlayer.duration = payload.duration * ONE_SECOND_IN_MILLISECONDS; }; /** This is for Native CLI Apps */ const handleProgress = (data: VideoProgressData) => { - const { currentTime, seekableDuration } = data; - // The duration given by the rn-video is not same as the one of the voice recording, so we take the actual duration for voice recording. - if (isVoiceRecording && item.duration) { - if (currentTime < item.duration && !audioFinished) { - onProgress(item.id, currentTime / item.duration); - } else { - setAudioFinished(true); - } - } else { - if (currentTime < seekableDuration && !audioFinished) { - onProgress(item.id, currentTime / seekableDuration); - } else { - setAudioFinished(true); - } - } + const { currentTime } = data; + audioPlayer.position = currentTime * ONE_SECOND_IN_MILLISECONDS; }; /** This is for Native CLI Apps */ const onSeek = (seekResponse: VideoSeekResponse) => { - setAudioFinished(false); - onProgress(item.id, seekResponse.currentTime / (item.duration as number)); + audioPlayer.position = seekResponse.currentTime * ONE_SECOND_IN_MILLISECONDS; }; - const handlePlayPause = async () => { - if (item.paused) { - if (isExpoCLI) { - await playAudio(); - } - onPlayPause(item.id, false); - } else { - if (isExpoCLI) { - await pauseAudio(); - } - onPlayPause(item.id, true); - } + const handlePlayPause = () => { + audioPlayer.toggle(); }; const handleEnd = async () => { - setAudioFinished(false); - await pauseAudio(); - onPlayPause(item.id, true); - await seekAudio(0); - }; - - const dragStart = async () => { - if (isExpoCLI) { - await pauseAudio(); - } - onPlayPause(item.id, true); + await audioPlayer.stop(); }; - const dragProgress = (progress: number) => { - onProgress(item.id, progress); + const dragStart = () => { + audioPlayer.pause(); }; - const dragEnd = async (progress: number) => { - await seekAudio(progress * (item.duration as number)); - if (isExpoCLI) { - await playAudio(); - } - onPlayPause(item.id, false); + const dragProgress = (currentProgress: number) => { + audioPlayer.progress = currentProgress; }; - /** For Expo CLI */ - const onPlaybackStatusUpdate = (playbackStatus: PlaybackStatus) => { - if (!playbackStatus.isLoaded) { - // Update your UI for the unloaded state - if (playbackStatus.error) { - console.log(`Encountered a fatal error during playback: ${playbackStatus.error}`); - } - } else { - const { durationMillis, positionMillis } = playbackStatus; - // This is done for Expo CLI where we don't get file duration from file picker - if (item.duration === 0) { - onLoad(item.id, durationMillis / 1000); - } else { - // 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. - if (isVoiceRecording && item.duration) { - onLoad(item.id, item.duration); - } else { - onLoad(item.id, durationMillis / 1000); - } - } - // Update your UI for the loaded state - if (playbackStatus.isPlaying) { - if (isVoiceRecording && item.duration) { - if (positionMillis <= item.duration * 1000) { - onProgress(item.id, positionMillis / (item.duration * 1000)); - } - } else { - if (positionMillis <= durationMillis) { - onProgress(item.id, positionMillis / durationMillis); - } - } - } else { - // Update your UI for the paused state - } - - if (playbackStatus.isBuffering) { - // Update your UI for the buffering state - } - - if (playbackStatus.didJustFinish && !playbackStatus.isLooping) { - onProgress(item.id, 1); - // The player has just finished playing and will stop. Maybe you want to play something else? - // status: opposite of pause,says i am playing - handleEnd(); - } - } + const dragEnd = async (currentProgress: number) => { + const positionInSeconds = (currentProgress * duration) / ONE_SECOND_IN_MILLISECONDS; + await audioPlayer.seek(positionInSeconds); + audioPlayer.play(); }; - // This is for Expo CLI, sound initialization is done here. - useEffect(() => { - if (isExpoCLI) { - const initiateSound = async () => { - if (item && item.asset_url && NativeHandlers.Sound?.initializeSound) { - soundRef.current = await NativeHandlers.Sound.initializeSound( - { uri: item.asset_url }, - { - pitchCorrectionQuality: 'high', - progressUpdateIntervalMillis: 100, - shouldCorrectPitch: true, - }, - onPlaybackStatusUpdate, - ); - } - }; - initiateSound(); - } - - return () => { - if (soundRef.current?.stopAsync && soundRef.current.unloadAsync) { - soundRef.current.stopAsync(); - soundRef.current.unloadAsync(); - } - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // This is needed for expo applications where the rerender doesn't occur on time thefore you need to update the state of the sound. - useEffect(() => { - const initalPlayPause = async () => { - if (!isExpoCLI) { - return; - } - try { - if (item.paused) { - await pauseAudio(); - } else { - await playAudio(); - } - } catch (e) { - console.log('An error has occurred while trying to interact with the audio. ', e); - } - }; - // For expo CLI - if (!NativeHandlers.Sound?.Player) { - initalPlayPause(); - } - }, [item.paused, isExpoCLI, pauseAudio, playAudio]); - const onSpeedChangeHandler = async () => { - if (currentSpeed === 2.0) { - setCurrentSpeed(1.0); - await changeAudioSpeed(1.0); - } else { - if (currentSpeed === 1.0) { - setCurrentSpeed(1.5); - await changeAudioSpeed(1.5); - } else if (currentSpeed === 1.5) { - setCurrentSpeed(2.0); - await changeAudioSpeed(2.0); - } - } + await audioPlayer.changePlaybackRate(); }; const { @@ -270,19 +195,14 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { }, } = useTheme(); - const progressValueInSeconds = useMemo( - () => (item.duration as number) * (item.progress as number), - [item.duration, item.progress], - ); - const progressDuration = useMemo( () => - progressValueInSeconds - ? progressValueInSeconds / 3600 >= 1 - ? dayjs.duration(progressValueInSeconds, 'second').format('HH:mm:ss') - : dayjs.duration(progressValueInSeconds, 'second').format('mm:ss') - : dayjs.duration(item.duration ?? 0, 'second').format('mm:ss'), - [progressValueInSeconds, item.duration], + position + ? position / ONE_HOUR_IN_MILLISECONDS >= 1 + ? dayjs.duration(position, 'milliseconds').format('HH:mm:ss') + : dayjs.duration(position, 'milliseconds').format('mm:ss') + : dayjs.duration(duration, 'milliseconds').format('mm:ss'), + [duration, position], ); return ( @@ -308,7 +228,7 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { playPauseButton, ]} > - {item.paused ? ( + {!isPlaying ? ( ) : ( @@ -328,7 +248,7 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { filenameText, ]} > - {item.type === FileTypes.VoiceRecording + {isVoiceRecordingAttachment(item) ? 'Recording' : getTrimmedAttachmentTitle(item.title, titleMaxLength)} @@ -344,17 +264,17 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { onEndDrag={dragEnd} onProgressDrag={dragProgress} onStartDrag={dragStart} - progress={item.progress as number} + progress={progress} waveformData={item.waveform_data} /> ) : ( )} @@ -367,8 +287,8 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { onLoad={handleLoad} onProgress={handleProgress} onSeek={onSeek} - paused={item.paused} - rate={currentSpeed} + paused={!isPlaying} + rate={currentPlaybackRate} soundRef={soundRef as RefObject} testID='sound-player' uri={item.asset_url} @@ -377,7 +297,7 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { {showSpeedSettings ? ( - {item.paused ? ( + {!isPlaying ? ( diff --git a/package/src/components/Attachment/FileAttachmentGroup.tsx b/package/src/components/Attachment/FileAttachmentGroup.tsx index a16fe5a7f..37d1ad1cb 100644 --- a/package/src/components/Attachment/FileAttachmentGroup.tsx +++ b/package/src/components/Attachment/FileAttachmentGroup.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; -import type { Attachment } from 'stream-chat'; +import { Attachment, isAudioAttachment, isVoiceRecordingAttachment } from 'stream-chat'; import { Attachment as AttachmentDefault } from './Attachment'; @@ -17,11 +17,10 @@ import { import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { isSoundPackageAvailable } from '../../native'; -import { FileTypes } from '../../types/types'; - -export type FileAttachmentGroupPropsWithContext = Pick & +export type FileAttachmentGroupPropsWithContext = Pick & Pick & { /** + * @deprecated Use message instead * The unique id for the message with file attachments */ messageId: string; @@ -38,7 +37,8 @@ type FilesToDisplayType = Attachment & { }; const FileAttachmentGroupWithContext = (props: FileAttachmentGroupPropsWithContext) => { - const { Attachment, AudioAttachment, files, messageId, styles: stylesProp = {} } = props; + const { Attachment, AudioAttachment, files, message, styles: stylesProp = {} } = props; + const [filesToDisplay, setFilesToDisplay] = useState(() => files.map((file) => ({ ...file, duration: file.duration || 0, paused: true, progress: 0 })), ); @@ -49,7 +49,14 @@ const FileAttachmentGroupWithContext = (props: FileAttachmentGroupPropsWithConte ); }, [files]); - // Handler triggered when an audio is loaded in the message input. The initial state is defined for the audio here and the duration is set. + /** + * Handler triggered when an audio is loaded in the message input. The initial state is defined for the audio here and the duration is set. + * @param index - The index of the audio + * @param duration - The duration of the audio + * + * @deprecated This is deprecated and will be removed in the future. + * FIXME: Remove this in the next major version. + */ const onLoad = (index: string, duration: number) => { setFilesToDisplay((prevFilesToDisplay) => prevFilesToDisplay.map((fileToDisplay, id) => ({ @@ -59,7 +66,14 @@ const FileAttachmentGroupWithContext = (props: FileAttachmentGroupPropsWithConte ); }; - // The handler which is triggered when the audio progresses/ the thumb is dragged in the progress control. The progressed duration is set here. + /** + * Handler which is triggered when the audio progresses/ the thumb is dragged in the progress control. The progressed duration is set here. + * @param index - The index of the audio + * @param progress - The progress of the audio + * + * @deprecated This is deprecated and will be removed in the future. + * FIXME: Remove this in the next major version. + */ const onProgress = (index: string, progress: number) => { setFilesToDisplay((prevFilesToDisplay) => prevFilesToDisplay.map((filesToDisplay, id) => ({ @@ -69,7 +83,14 @@ const FileAttachmentGroupWithContext = (props: FileAttachmentGroupPropsWithConte ); }; - // The handler which controls or sets the paused/played state of the audio. + /** + * Handler which controls or sets the paused/played state of the audio. + * @param index - The index of the audio + * @param pausedStatus - The paused status of the audio + * + * @deprecated This is deprecated and will be removed in the future. + * FIXME: Remove this in the next major version. + */ const onPlayPause = (index: string, pausedStatus?: boolean) => { if (pausedStatus === false) { // If the status is false we set the audio with the index as playing and the others as paused. @@ -102,21 +123,18 @@ const FileAttachmentGroupWithContext = (props: FileAttachmentGroupPropsWithConte {filesToDisplay.map((file, index) => ( - {(file.type === FileTypes.Audio || file.type === FileTypes.VoiceRecording) && + {(isAudioAttachment(file) || isVoiceRecordingAttachment(file)) && isSoundPackageAvailable() ? ( { - const { files: prevFiles } = prevProps; - const { files: nextFiles } = nextProps; + const { files: prevFiles, message: prevMessage } = prevProps; + const { files: nextFiles, message: nextMessage } = nextProps; + + const messageEqual = prevMessage?.id === nextMessage?.id; + if (!messageEqual) { + return false; + } return prevFiles.length === nextFiles.length; }; @@ -154,7 +177,7 @@ export type FileAttachmentGroupProps = Partial< export const FileAttachmentGroup = (props: FileAttachmentGroupProps) => { const { files: propFiles, messageId } = props; - const { files: contextFiles } = useMessageContext(); + const { files: contextFiles, message } = useMessageContext(); const { Attachment = AttachmentDefault, AudioAttachment } = useMessagesContext(); @@ -170,6 +193,7 @@ export const FileAttachmentGroup = (props: FileAttachmentGroupProps) => { Attachment, AudioAttachment, files, + message, messageId, }} /> diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index b2674cdd7..5a149ea9a 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -52,6 +52,10 @@ import { AttachmentPickerProvider, MessageContextValue, } from '../../contexts'; +import { + AudioPlayerContextProps, + WithAudioPlayback, +} from '../../contexts/audioPlayerContext/AudioPlayerContext'; import { ChannelContextValue, ChannelProvider } from '../../contexts/channelContext/ChannelContext'; import type { UseChannelStateValue } from '../../contexts/channelsStateContext/useChannelState'; import { useChannelState } from '../../contexts/channelsStateContext/useChannelState'; @@ -487,6 +491,11 @@ export type ChannelPropsWithContext = Pick & */ newMessageStateUpdateThrottleInterval?: number; overrideOwnCapabilities?: Partial; + /** + * If true, multiple audio players will be allowed to play simultaneously + * @default true + */ + allowConcurrentAudioPlayback?: boolean; stateUpdateThrottleInterval?: number; /** * Tells if channel is rendering a thread list @@ -506,6 +515,7 @@ const ChannelWithContext = (props: PropsWithChildren) = additionalKeyboardAvoidingViewProps, additionalPressableProps, additionalTextInputProps, + allowConcurrentAudioPlayback = false, allowThreadMessagesInChannel = true, asyncMessagesLockDistance = 50, asyncMessagesMinimumPressDuration = 500, @@ -1657,6 +1667,13 @@ const ChannelWithContext = (props: PropsWithChildren) = } }); + const audioPlayerProviderProps = useMemo( + () => ({ + allowConcurrentAudioPlayback, + }), + [allowConcurrentAudioPlayback], + ); + const attachmentPickerProps = useMemo( () => ({ AttachmentPickerBottomSheetHandle, @@ -1995,10 +2012,12 @@ const ChannelWithContext = (props: PropsWithChildren) = - {children} - {!disableAttachmentPicker && ( - - )} + + {children} + {!disableAttachmentPicker && ( + + )} + diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index f3f4781c8..7ced90ea0 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -453,6 +453,12 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { paused={paused} position={position} progress={progress} + recordingDuration={recordingDuration} + uri={ + typeof recording !== 'string' + ? (recording?.getURI() as string) + : (recording as string) + } waveformData={waveformData} /> ) : micLocked ? ( diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx index b06c71309..d1de2afb5 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx @@ -10,6 +10,7 @@ import { DismissAttachmentUpload } from './DismissAttachmentUpload'; import { AudioAttachment } from '../../../../components/Attachment/AudioAttachment'; import { useChatContext } from '../../../../contexts/chatContext/ChatContext'; +import { useMessageComposer } from '../../../../contexts/messageInputContext/hooks/useMessageComposer'; import { AudioConfig, UploadAttachmentPreviewProps } from '../../../../types/types'; import { getIndicatorTypeForFileState, ProgressIndicatorTypes } from '../../../../utils/utils'; @@ -17,9 +18,26 @@ export type AudioAttachmentUploadPreviewProps | LocalVoiceRecordingAttachment > & { + /** + * The audio attachment config + * + * @deprecated This is deprecated and will be removed in the future. + */ audioAttachmentConfig: AudioConfig; + /** + * Callback to be called when the audio is loaded + * @deprecated This is deprecated and will be removed in the future. + */ onLoad: (index: string, duration: number) => void; + /** + * Callback to be called when the audio is played or paused + * @deprecated This is deprecated and will be removed in the future. + */ onPlayPause: (index: string, pausedStatus?: boolean) => void; + /** + * Callback to be called when the audio progresses + * @deprecated This is deprecated and will be removed in the future. + */ onProgress: (index: string, progress: number) => void; }; @@ -37,15 +55,23 @@ export const AudioAttachmentUploadPreview = ({ attachment.localMetadata.uploadState, enableOfflineSupport, ); + const messageComposer = useMessageComposer(); + const isDraft = messageComposer.draftId; + const isEditing = messageComposer.editedMessage; + const assetUrl = + (isDraft || isEditing + ? attachment.asset_url + : (attachment.localMetadata.file as FileReference).uri) ?? + (attachment.localMetadata.file as FileReference).uri; const finalAttachment = useMemo( () => ({ ...attachment, - asset_url: attachment.asset_url ?? (attachment.localMetadata.file as FileReference).uri, + asset_url: assetUrl, id: attachment.localMetadata.id, ...audioAttachmentConfig, }), - [attachment, audioAttachmentConfig], + [attachment, assetUrl, audioAttachmentConfig], ); const onRetryHandler = useCallback(() => { @@ -65,6 +91,7 @@ export const AudioAttachmentUploadPreview = ({ > Promise; }; +const audioPlayerSelector = (state: AudioPlayerState) => ({ + duration: state.duration, + isPlaying: state.isPlaying, + position: state.position, + progress: state.progress, +}); + /** * Component displayed when the audio is recorded and can be previewed. */ export const AudioRecordingPreview = (props: AudioRecordingPreviewProps) => { - const { onVoicePlayerPlayPause, paused, position, progress, waveformData } = props; + const { recordingDuration, uri, waveformData } = props; + + const audioPlayer = useAudioPlayerControl({ + duration: recordingDuration / ONE_SECOND_IN_MILLISECONDS, + mimeType: 'audio/aac', + // This is a temporary flag to manage audio player for voice recording in preview as the one in message list uses react-native-video. + previewVoiceRecording: !(NativeHandlers.SDK === 'stream-chat-expo'), + type: 'voiceRecording', + uri, + }); + + const { duration, isPlaying, position, progress } = useStateStore( + audioPlayer.state, + audioPlayerSelector, + ); + + // When a audio attachment in preview is removed, we need to remove the player from the pool + useEffect( + () => () => { + audioPlayer.onRemove(); + }, + [audioPlayer], + ); const { theme: { @@ -53,11 +103,25 @@ export const AudioRecordingPreview = (props: AudioRecordingPreviewProps) => { }, } = useTheme(); + const handlePlayPause = () => { + audioPlayer.toggle(); + }; + + const progressDuration = useMemo( + () => + position + ? position / ONE_HOUR_IN_MILLISECONDS >= 1 + ? dayjs.duration(position, 'milliseconds').format('HH:mm:ss') + : dayjs.duration(position, 'milliseconds').format('mm:ss') + : dayjs.duration(duration, 'milliseconds').format('mm:ss'), + [duration, position], + ); + return ( - - {paused ? ( + + {!isPlaying ? ( ) : ( @@ -65,7 +129,7 @@ export const AudioRecordingPreview = (props: AudioRecordingPreviewProps) => { {/* `durationMillis` is for Expo apps, `currentPosition` is for Native CLI apps. */} - {dayjs.duration(position).format('mm:ss')} + {progressDuration} diff --git a/package/src/components/MessageInput/hooks/useAudioController.tsx b/package/src/components/MessageInput/hooks/useAudioController.tsx index 43d25e595..3a00b451e 100644 --- a/package/src/components/MessageInput/hooks/useAudioController.tsx +++ b/package/src/components/MessageInput/hooks/useAudioController.tsx @@ -24,12 +24,29 @@ export type RecordingStatusStates = 'idle' | 'recording' | 'stopped'; /** * The hook that controls all the async audio core features including start/stop or recording, player, upload/delete of the recorded audio. + * + * FIXME: Change the name to `useAudioRecorder` in the next major version as the hook will only be used for audio recording. */ export const useAudioController = () => { const [micLocked, setMicLocked] = useState(false); const [permissionsGranted, setPermissionsGranted] = useState(true); + /** + * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager. + * Check: https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx on how to use the global audio manager. + * FIXME: Remove this in the next major version. + */ const [paused, setPaused] = useState(true); + /** + * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager. + * Check: https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx on how to use the global audio manager. + * FIXME: Remove this in the next major version. + */ const [position, setPosition] = useState(0); + /** + * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager. + * Check: https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx on how to use the global audio manager. + * FIXME: Remove this in the next major version. + */ const [progress, setProgress] = useState(0); const [waveformData, setWaveformData] = useState([]); const [isScheduledForSubmit, setIsScheduleForSubmit] = useState(false); @@ -40,7 +57,14 @@ export const useAudioController = () => { const { sendMessage } = useMessageInputContext(); - // For playback support in Expo CLI apps + /** + * Reference to the sound object for playback support in Expo CLI apps + * + * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager. + * Check: https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx on how to use the global audio manager. + * + * FIXME: Remove this in the next major version. + */ const soundRef = useRef(null); // This effect stop the player from playing and stops audio recording on @@ -60,6 +84,12 @@ export const useAudioController = () => { } }, [isScheduledForSubmit, sendMessage]); + /** + * Function to update the progress of the voice recording. + * + * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager. + * FIXME: Remove this in the next major version. + */ const onVoicePlayerProgressHandler = (currentPosition: number, playbackDuration: number) => { const currentProgress = currentPosition / playbackDuration; if (currentProgress === 1) { @@ -70,6 +100,14 @@ export const useAudioController = () => { } }; + /** + * Function to update the playback status of the voice recording. + * + * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager. + * Check: https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx on how to use the global audio manager. + * + * FIXME: Remove this in the next major version. + */ const onVoicePlayerPlaybackStatusUpdate = (status: PlaybackStatus) => { if (status.shouldPlay === undefined || status.shouldPlay === true) { setPosition(status?.currentPosition || status?.positionMillis); @@ -90,6 +128,14 @@ export const useAudioController = () => { } }; + /** + * Function to play or pause voice recording. + * + * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager. + * Check: https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx on how to use the global audio manager. + * + * FIXME: Remove this in the next major version. + */ const onVoicePlayerPlayPause = async () => { if (paused) { if (progress === 0) { @@ -119,6 +165,11 @@ export const useAudioController = () => { /** * Function to start playing voice recording to preview it after recording. + * + * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager. + * Check: https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx on how to use the global audio manager. + * + * FIXME: Remove this in the next major version. */ const startVoicePlayer = async () => { if (!recording) { @@ -150,6 +201,11 @@ export const useAudioController = () => { /** * Function to stop playing voice recording. + * + * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager. + * Check: https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx on how to use the global audio manager. + * + * FIXME: Remove this in the next major version. */ const stopVoicePlayer = async () => { // For Native CLI diff --git a/package/src/components/MessageInput/hooks/useAudioPreviewManager.tsx b/package/src/components/MessageInput/hooks/useAudioPreviewManager.tsx index b0c6936bd..31af18f47 100644 --- a/package/src/components/MessageInput/hooks/useAudioPreviewManager.tsx +++ b/package/src/components/MessageInput/hooks/useAudioPreviewManager.tsx @@ -8,6 +8,8 @@ import { AudioConfig } from '../../../types/types'; * Manages the state of audio attachments for preview and playback. * @param files The audio files to manage. * @returns An object containing the state and handlers for audio attachments. + * + * @deprecated This is deprecated and will be removed in the future. */ export const useAudioPreviewManager = (files: LocalAttachment[]) => { const [audioAttachmentsStateMap, setAudioAttachmentsStateMap] = useState< @@ -34,8 +36,15 @@ export const useAudioPreviewManager = (files: LocalAttachment[]) => { }); }, [files]); - // Handler triggered when an audio is loaded in the message input. The initial state is defined for the audio here - // and the duration is set. + /** + * Handler triggered when an audio is loaded in the message input. The initial state is defined for the audio here + * and the duration is set. + * @param index - The index of the audio + * @param duration - The duration of the audio + * + * @deprecated This is deprecated and will be removed in the future. + * FIXME: Remove this in the next major version. + */ const onLoad = useCallback((index: string, duration: number) => { setAudioAttachmentsStateMap((prevState) => ({ ...prevState, @@ -46,8 +55,15 @@ export const useAudioPreviewManager = (files: LocalAttachment[]) => { })); }, []); - // The handler which is triggered when the audio progresses/ the thumb is dragged in the progress control. The - // progressed duration is set here. + /** + * Handler which is triggered when the audio progresses/ the thumb is dragged in the progress control. The + * progressed duration is set here. + * @param index - The index of the audio + * @param progress - The progress of the audio + * + * @deprecated This is deprecated and will be removed in the future. + * FIXME: Remove this in the next major version. + */ const onProgress = useCallback((index: string, progress: number) => { setAudioAttachmentsStateMap((prevState) => ({ ...prevState, @@ -58,7 +74,14 @@ export const useAudioPreviewManager = (files: LocalAttachment[]) => { })); }, []); - // The handler which controls or sets the paused/played state of the audio. + /** + * Handler which controls or sets the paused/played state of the audio. + * @param index - The index of the audio + * @param pausedStatus - The paused status of the audio + * + * @deprecated This is deprecated and will be removed in the future. + * FIXME: Remove this in the next major version. + */ const onPlayPause = useCallback((index: string, pausedStatus?: boolean) => { if (pausedStatus === false) { // In this case, all others except the index are set to paused. diff --git a/package/src/components/ProgressControl/ProgressControl.tsx b/package/src/components/ProgressControl/ProgressControl.tsx index 89acc8ebb..19dd1e14f 100644 --- a/package/src/components/ProgressControl/ProgressControl.tsx +++ b/package/src/components/ProgressControl/ProgressControl.tsx @@ -1,12 +1,18 @@ -import React, { useState } from 'react'; +import React, { useRef, useState } from 'react'; import { StyleSheet, View } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; -import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from 'react-native-reanimated'; +import Animated, { + runOnJS, + useAnimatedReaction, + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; export type ProgressControlProps = { /** + * @deprecated unused prop. * The duration of the audio in seconds */ duration: number; @@ -33,6 +39,7 @@ export type ProgressControlProps = { onPlayPause?: (status?: boolean) => void; /** * The function to be called when the user is dragging the progress bar + * @deprecated This is not used anymore and is handled locally */ onProgressDrag?: (progress: number) => void; /** @@ -67,17 +74,10 @@ const ProgressControlThumb = () => { export const ProgressControl = (props: ProgressControlProps) => { const [widthInNumbers, setWidthInNumbers] = useState(0); - const { - filledColor: filledColorFromProp, - onEndDrag, - onPlayPause, - onProgressDrag, - onStartDrag, - progress, - testID, - } = props; + const { filledColor: filledColorFromProp, onEndDrag, onStartDrag, progress, testID } = props; - const progressValue = useSharedValue(progress); + const state = useSharedValue(progress); + const isSliding = useRef(false); const { theme: { colors: { grey_dark }, @@ -85,33 +85,32 @@ export const ProgressControl = (props: ProgressControlProps) => { }, } = useTheme(); + useAnimatedReaction( + () => progress, + (newProgress) => { + if (!isSliding.current) { + state.value = newProgress; + } + }, + [progress, isSliding.current], + ); + const pan = Gesture.Pan() .maxPointers(1) - .onStart((event) => { - const currentProgress = (progressValue.value + event.x) / widthInNumbers; - progressValue.value = Math.max(0, Math.min(currentProgress, 1)); + .onStart(() => { + isSliding.current = true; if (onStartDrag) { - runOnJS(onStartDrag)(progressValue.value); - } - if (onPlayPause) { - runOnJS(onPlayPause)(true); + runOnJS(onStartDrag)(state.value); } }) .onUpdate((event) => { - const currentProgress = (progressValue.value + event.x) / widthInNumbers; - progressValue.value = Math.max(0, Math.min(currentProgress, 1)); - if (onProgressDrag) { - runOnJS(onProgressDrag)(progressValue.value); - } + const newProgress = Math.max(0, Math.min(event.x / widthInNumbers, 1)); + state.value = newProgress; }) - .onEnd((event) => { - const currentProgress = (progressValue.value + event.x) / widthInNumbers; - progressValue.value = Math.max(0, Math.min(currentProgress, 1)); + .onEnd(() => { + isSliding.current = false; if (onEndDrag) { - runOnJS(onEndDrag)(progressValue.value); - } - if (onPlayPause) { - runOnJS(onPlayPause)(false); + runOnJS(onEndDrag)(state.value); } }) .withTestId(testID); @@ -120,16 +119,16 @@ export const ProgressControl = (props: ProgressControlProps) => { const thumbStyles = useAnimatedStyle( () => ({ - transform: [{ translateX: progress * widthInNumbers - THUMB_WIDTH / 2 }], + transform: [{ translateX: state.value * widthInNumbers - THUMB_WIDTH / 2 }], }), - [progress], + [widthInNumbers], ); const animatedFilledStyles = useAnimatedStyle( () => ({ - width: progress * widthInNumbers, + width: state.value * widthInNumbers, }), - [progress], + [widthInNumbers], ); return ( @@ -151,7 +150,7 @@ export const ProgressControl = (props: ProgressControlProps) => { ]} /> - {onEndDrag || onProgressDrag ? : null} + {onEndDrag ? : null} diff --git a/package/src/components/ProgressControl/WaveProgressBar.tsx b/package/src/components/ProgressControl/WaveProgressBar.tsx index d454caa56..f230d5a5b 100644 --- a/package/src/components/ProgressControl/WaveProgressBar.tsx +++ b/package/src/components/ProgressControl/WaveProgressBar.tsx @@ -1,7 +1,12 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; import { Platform, StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; -import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from 'react-native-reanimated'; +import Animated, { + runOnJS, + useAnimatedReaction, + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { resampleWaveformData } from '../MessageInput/utils/audioSampling'; @@ -76,7 +81,6 @@ export const WaveProgressBar = React.memo( amplitudesCount = 70, filledColor, onEndDrag, - onPlayPause, onProgressDrag, onStartDrag, progress, @@ -85,6 +89,7 @@ export const WaveProgressBar = React.memo( const eachWaveformWidth = WAVEFORM_WIDTH * 2; const fullWidth = (amplitudesCount - 1) * eachWaveformWidth; const state = useSharedValue(progress); + const isSliding = useRef(false); const [currentWaveformProgress, setCurrentWaveformProgress] = useState(0); const waveFormNumberFromProgress = useCallback( @@ -98,9 +103,16 @@ export const WaveProgressBar = React.memo( [fullWidth], ); - useEffect(() => { - waveFormNumberFromProgress(progress); - }, [progress, waveFormNumberFromProgress]); + useAnimatedReaction( + () => progress, + (newProgress) => { + if (!isSliding.current) { + state.value = newProgress; + } + waveFormNumberFromProgress(newProgress); + }, + [progress], + ); const { theme: { @@ -112,32 +124,22 @@ export const WaveProgressBar = React.memo( const pan = Gesture.Pan() .enabled(showProgressDrag) .maxPointers(1) - .onStart((event) => { - const currentProgress = (state.value + event.x) / fullWidth; - state.value = Math.max(0, Math.min(currentProgress, 1)); + .onStart(() => { + isSliding.current = true; if (onStartDrag) { runOnJS(onStartDrag)(state.value); } - if (onPlayPause) { - runOnJS(onPlayPause)(true); - } }) .onUpdate((event) => { - const currentProgress = (state.value + event.x) / fullWidth; - state.value = Math.max(0, Math.min(currentProgress, 1)); - if (onProgressDrag) { - runOnJS(onProgressDrag)(state.value); - } + const newProgress = Math.max(0, Math.min(event.x / fullWidth, 1)); + state.value = newProgress; + waveFormNumberFromProgress(newProgress); }) - .onEnd((event) => { - const currentProgress = (state.value + event.x) / fullWidth; - state.value = Math.max(0, Math.min(currentProgress, 1)); + .onEnd(() => { + isSliding.current = false; if (onEndDrag) { runOnJS(onEndDrag)(state.value); } - if (onPlayPause) { - runOnJS(onPlayPause)(false); - } }); const stringifiedWaveformData = waveformData.toString(); diff --git a/package/src/contexts/audioPlayerContext/AudioPlayerContext.tsx b/package/src/contexts/audioPlayerContext/AudioPlayerContext.tsx new file mode 100644 index 000000000..43de9f680 --- /dev/null +++ b/package/src/contexts/audioPlayerContext/AudioPlayerContext.tsx @@ -0,0 +1,54 @@ +import React, { createContext, PropsWithChildren, useContext, useEffect, useMemo } from 'react'; + +import { useStateStore } from '../../hooks/useStateStore'; +import { AudioPlayerPool, AudioPlayerPoolState } from '../../state-store/audio-player-pool'; +import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; + +export type AudioPlayerContextProps = { + allowConcurrentAudioPlayback: boolean; +}; + +export type AudioPlayerContextValue = { + audioPlayerPool: AudioPlayerPool; +}; + +export const AudioPlayerContext = createContext( + DEFAULT_BASE_CONTEXT_VALUE as AudioPlayerContextValue, +); + +export const WithAudioPlayback = ({ + props: { allowConcurrentAudioPlayback }, + children, +}: PropsWithChildren<{ props: AudioPlayerContextProps }>) => { + const audioPlayerPool = useMemo( + () => new AudioPlayerPool({ allowConcurrentAudioPlayback }), + [allowConcurrentAudioPlayback], + ); + const audioPlayerPoolContextValue = useMemo(() => ({ audioPlayerPool }), [audioPlayerPool]); + + useEffect( + () => () => { + audioPlayerPool.clear(); + }, + [audioPlayerPool], + ); + + return ( + + {children} + + ); +}; + +export const useAudioPlayerContext = () => useContext(AudioPlayerContext); + +const activeAudioPlayerSelector = ({ activeAudioPlayer }: AudioPlayerPoolState) => ({ + activeAudioPlayer, +}); + +export const useActiveAudioPlayer = () => { + const { audioPlayerPool } = useContext(AudioPlayerContext); + const { activeAudioPlayer } = + useStateStore(audioPlayerPool.state, activeAudioPlayerSelector) ?? {}; + return activeAudioPlayer; +}; diff --git a/package/src/contexts/index.ts b/package/src/contexts/index.ts index 0ec7f3d63..20096a63f 100644 --- a/package/src/contexts/index.ts +++ b/package/src/contexts/index.ts @@ -28,3 +28,4 @@ export * from './typingContext/TypingContext'; export * from './utils/getDisplayName'; export * from './pollContext'; export * from './liveLocationManagerContext'; +export * from './audioPlayerContext/AudioPlayerContext'; diff --git a/package/src/hooks/useAudioPlayer.ts b/package/src/hooks/useAudioPlayer.ts index a96c5e750..bb8dd5ccc 100644 --- a/package/src/hooks/useAudioPlayer.ts +++ b/package/src/hooks/useAudioPlayer.ts @@ -9,6 +9,8 @@ export type UseSoundPlayerProps = { /** * This hook is used to play, pause, seek and change audio speed. * It handles both Expo CLI and Native CLI. + * + * @deprecated This is deprecated and will be removed in the future. */ export const useAudioPlayer = (props: UseSoundPlayerProps) => { const { soundRef } = props; @@ -40,24 +42,21 @@ export const useAudioPlayer = (props: UseSoundPlayerProps) => { }, [isExpoCLI, soundRef]); const seekAudio = useCallback( - async (currentTime: number) => { + async (currentTimeInSeconds: number = 0) => { if (isExpoCLI) { - if (currentTime === 0) { + if (currentTimeInSeconds === 0) { // If currentTime is 0, we should replay the video from 0th position. if (soundRef.current?.replayAsync) { - await soundRef.current.replayAsync({ - positionMillis: 0, - shouldPlay: false, - }); + await soundRef.current.replayAsync({}); } } else { if (soundRef.current?.setPositionAsync) { - await soundRef.current.setPositionAsync(currentTime * 1000); + await soundRef.current.setPositionAsync(currentTimeInSeconds); } } } else { if (soundRef.current?.seek) { - soundRef.current.seek(currentTime); + soundRef.current.seek(currentTimeInSeconds); } } }, diff --git a/package/src/hooks/useAudioPlayerControl.ts b/package/src/hooks/useAudioPlayerControl.ts new file mode 100644 index 000000000..cbe30f170 --- /dev/null +++ b/package/src/hooks/useAudioPlayerControl.ts @@ -0,0 +1,59 @@ +import { useMemo } from 'react'; + +import { useAudioPlayerContext } from '../contexts/audioPlayerContext/AudioPlayerContext'; +import { AudioPlayerOptions } from '../state-store/audio-player'; + +export type UseAudioPlayerControlProps = { + /** + * Identifier of the entity that requested the audio playback, e.g. message ID. + * Asset to specific audio player is a many-to-many relationship + * - one URL can be associated with multiple UI elements, + * - one UI element can display multiple audio sources. + * Therefore, the AudioPlayer ID is a combination of request:src. + * + * The requester string can take into consideration whether there are multiple instances of + * the same URL requested by the same requester (message has multiple attachments with the same asset URL). + * In reality the fact that one message has multiple attachments with the same asset URL + * could be considered a bad practice or a bug. + */ + requester?: string; +} & Partial; + +const makeAudioPlayerId = ({ + requester, + src, + id, +}: { + src: string; + requester?: string; + id?: string; +}) => `${requester ?? 'requester-unknown'}:${src}:${id ?? ''}`; + +export const useAudioPlayerControl = ({ + duration, + mimeType, + playbackRates, + previewVoiceRecording, + requester = '', + type, + uri, + id: fileId, +}: UseAudioPlayerControlProps) => { + const { audioPlayerPool } = useAudioPlayerContext(); + const id = makeAudioPlayerId({ id: fileId, requester, src: uri ?? '' }); + const audioPlayer = useMemo( + () => + audioPlayerPool?.getOrAddPlayer({ + duration: duration ?? 0, + id, + mimeType: mimeType ?? '', + playbackRates, + previewVoiceRecording, + type: type ?? 'audio', + uri: uri ?? '', + }), + [audioPlayerPool, duration, id, mimeType, playbackRates, previewVoiceRecording, type, uri], + ); + + return audioPlayer; +}; diff --git a/package/src/hooks/useInAppNotificationsState.ts b/package/src/hooks/useInAppNotificationsState.ts index 2e4237f9e..f9f275bcc 100644 --- a/package/src/hooks/useInAppNotificationsState.ts +++ b/package/src/hooks/useInAppNotificationsState.ts @@ -3,12 +3,12 @@ import { Notification } from 'stream-chat'; import { useStableCallback } from './useStableCallback'; import { useStateStore } from './useStateStore'; -import type { InAppNotificationsState } from '../store/in-app-notifications-store'; +import type { InAppNotificationsState } from '../state-store/in-app-notifications-store'; import { closeInAppNotification, inAppNotificationsStore, openInAppNotification, -} from '../store/in-app-notifications-store'; +} from '../state-store/in-app-notifications-store'; const selector = ({ notifications }: InAppNotificationsState) => ({ notifications, diff --git a/package/src/index.ts b/package/src/index.ts index db0562513..2c4d7c5d0 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -31,7 +31,7 @@ export { default as ptBRTranslations } from './i18n/pt-br.json'; export { default as ruTranslations } from './i18n/ru.json'; export { default as trTranslations } from './i18n/tr.json'; -export * from './store'; +export * from './state-store'; export { SqliteClient } from './store/SqliteClient'; export { OfflineDB } from './store/OfflineDB'; export { version } from './version.json'; diff --git a/package/src/native.ts b/package/src/native.ts index f20d52b09..cfcb627e5 100644 --- a/package/src/native.ts +++ b/package/src/native.ts @@ -227,6 +227,7 @@ export type AudioType = { stopRecording: () => Promise; pausePlayer?: () => Promise; resumePlayer?: () => Promise; + seekToPlayer?: (positionInMillis: number) => Promise; startPlayer?: ( uri?: AudioRecordingReturnType, initialStatus?: Partial, diff --git a/package/src/state-store/audio-player-pool.ts b/package/src/state-store/audio-player-pool.ts new file mode 100644 index 000000000..aafadb56a --- /dev/null +++ b/package/src/state-store/audio-player-pool.ts @@ -0,0 +1,94 @@ +import { StateStore } from 'stream-chat'; + +import { AudioPlayer, AudioPlayerOptions } from './audio-player'; + +export type AudioPlayerPoolOptions = { + allowConcurrentAudioPlayback: boolean; +}; + +export type AudioPlayerPoolState = { + activeAudioPlayer: AudioPlayer | null; +}; + +export class AudioPlayerPool { + pool: Map; + allowConcurrentAudioPlayback: boolean; + state: StateStore = new StateStore({ + activeAudioPlayer: null, + }); + + constructor({ allowConcurrentAudioPlayback }: AudioPlayerPoolOptions) { + this.pool = new Map(); + this.allowConcurrentAudioPlayback = allowConcurrentAudioPlayback ?? false; + } + + get players() { + return Array.from(this.pool.values()); + } + + getOrAddPlayer(params: AudioPlayerOptions) { + const player = this.pool.get(params.id); + if (player) { + return player; + } + const newPlayer = new AudioPlayer(params); + newPlayer.pool = this; + + this.pool.set(params.id, newPlayer); + return newPlayer; + } + + setActivePlayer(activeAudioPlayer: AudioPlayer | null) { + this.state.partialNext({ + activeAudioPlayer, + }); + } + + getActivePlayer() { + return this.state.getLatestValue().activeAudioPlayer; + } + + removePlayer(id: string) { + const player = this.pool.get(id); + if (!player) return; + player.onRemove(); + this.pool.delete(id); + + if (this.getActivePlayer()?.id === id) { + this.setActivePlayer(null); + } + } + + deregister(id: string) { + if (this.pool.has(id)) { + this.pool.delete(id); + } + } + + clear() { + for (const player of this.pool.values()) { + this.removePlayer(player.id); + } + this.setActivePlayer(null); + } + + requestPlay(id: string) { + if (this.allowConcurrentAudioPlayback) return; + + if (this.getActivePlayer()?.id !== id) { + const currentPlayer = this.getActivePlayer(); + if (currentPlayer && currentPlayer.isPlaying) { + currentPlayer.pause(); + } + } + + const activePlayer = this.pool.get(id); + if (activePlayer) { + this.setActivePlayer(activePlayer); + } + } + + notifyPaused() { + this.setActivePlayer(null); + } +} diff --git a/package/src/state-store/audio-player.ts b/package/src/state-store/audio-player.ts new file mode 100644 index 000000000..dc38023c5 --- /dev/null +++ b/package/src/state-store/audio-player.ts @@ -0,0 +1,372 @@ +import { StateStore } from 'stream-chat'; + +import { AudioPlayerPool } from './audio-player-pool'; + +import { AVPlaybackStatusToSet, NativeHandlers, PlaybackStatus, SoundReturnType } from '../native'; + +export type AudioDescriptor = { + id: string; + uri: string; + duration: number; + mimeType: string; + type: 'voiceRecording' | 'audio'; +}; + +export type AudioPlayerState = { + isPlaying: boolean; + duration: number; + position: number; + progress: number; + currentPlaybackRate: number; + playbackRates: number[]; +}; + +const DEFAULT_PLAYBACK_RATES = [1.0, 1.5, 2.0]; + +const DEFAULT_PLAYER_SETTINGS = { + pitchCorrectionQuality: 'high', + progressUpdateIntervalMillis: 100, + shouldCorrectPitch: true, +} as AVPlaybackStatusToSet; + +const INITIAL_STATE: AudioPlayerState = { + currentPlaybackRate: 1.0, + duration: 0, + isPlaying: false, + playbackRates: DEFAULT_PLAYBACK_RATES, + position: 0, + progress: 0, +}; + +export type AudioPlayerOptions = AudioDescriptor & { + playbackRates?: number[]; + previewVoiceRecording?: boolean; +}; + +export class AudioPlayer { + state: StateStore; + playerRef: SoundReturnType | null = null; + private _id: string; + private type: 'voiceRecording' | 'audio'; + private isExpoCLI: boolean; + private _pool: AudioPlayerPool | null = null; + + /** + * This is a temporary flag to manage audio player for voice recording in preview as the one in message list uses react-native-video. + * We can get rid of this when we migrate to the react-native-nitro-sound everywhere. + */ + private previewVoiceRecording?: boolean; + + constructor(options: AudioPlayerOptions) { + this.isExpoCLI = NativeHandlers.SDK === 'stream-chat-expo'; + this._id = options.id; + this.type = options.type; + this.previewVoiceRecording = options.previewVoiceRecording ?? false; + const playbackRates = options.playbackRates ?? DEFAULT_PLAYBACK_RATES; + this.state = new StateStore({ + ...INITIAL_STATE, + currentPlaybackRate: playbackRates[0], + duration: options.duration * 1000, + playbackRates, + }); + this.initPlayer({ uri: options.uri }); + } + + // Initialize the expo player + // In the future we will also initialize the native cli player here. + initPlayer = async ({ uri, playerRef }: { uri?: string; playerRef?: SoundReturnType }) => { + if (playerRef) { + this.playerRef = playerRef; + return; + } + if (this.previewVoiceRecording) { + if (NativeHandlers.Audio?.startPlayer) { + await NativeHandlers.Audio.startPlayer( + uri, + {}, + this.onVoiceRecordingPreviewPlaybackStatusUpdate, + ); + if (NativeHandlers.Audio?.pausePlayer) { + await NativeHandlers.Audio.pausePlayer(); + } + } + return; + } + if (!this.isExpoCLI || !uri) { + return; + } + if (NativeHandlers.Sound?.initializeSound) { + this.playerRef = await NativeHandlers.Sound?.initializeSound( + { uri }, + DEFAULT_PLAYER_SETTINGS, + this.onPlaybackStatusUpdate, + ); + } + }; + + private onVoiceRecordingPreviewPlaybackStatusUpdate = async (playbackStatus: PlaybackStatus) => { + const currentProgress = playbackStatus.currentPosition / playbackStatus.duration; + if (currentProgress === 1) { + await this.stop(); + } else { + this.progress = currentProgress; + } + }; + + // This should be a arrow function to avoid binding the function to the instance + private onPlaybackStatusUpdate = async (playbackStatus: PlaybackStatus) => { + if (!playbackStatus.isLoaded) { + // Update your UI for the unloaded state + if (playbackStatus.error) { + console.log(`Encountered a fatal error during playback: ${playbackStatus.error}`); + } + } else { + const { durationMillis, positionMillis } = playbackStatus; + // Update your UI for the loaded state + // This is done for Expo CLI where we don't get file duration from file picker + + if (this.type !== 'voiceRecording') { + this.duration = durationMillis; + } + + // Update the position of the audio player when it is playing + if (playbackStatus.isPlaying) { + // 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. + const duration = this.type === 'voiceRecording' ? this.duration : durationMillis; + if (positionMillis <= duration) { + this.position = positionMillis; + } + } + + // Update the UI when the audio is finished playing + if (playbackStatus.didJustFinish && !playbackStatus.isLooping) { + await this.stop(); + } + } + }; + + // Getters + get isPlaying() { + return this.state.getLatestValue().isPlaying; + } + + get duration() { + return this.state.getLatestValue().duration; + } + + get position() { + return this.state.getLatestValue().position; + } + + get progress() { + return this.state.getLatestValue().progress; + } + + get playbackRates() { + return this.state.getLatestValue().playbackRates; + } + + get currentPlaybackRate() { + return this.state.getLatestValue().currentPlaybackRate; + } + + get id() { + return this._id; + } + + // Setters + set pool(pool: AudioPlayerPool) { + this._pool = pool; + } + + set duration(duration: number) { + this.state.partialNext({ + duration, + }); + } + + set position(position: number) { + this.state.partialNext({ + position, + progress: position / this.duration, + }); + } + + set progress(progress: number) { + this.state.partialNext({ + position: progress * this.duration, + progress, + }); + } + + set isPlaying(isPlaying: boolean) { + this.state.partialNext({ + isPlaying, + }); + } + + // Methods + async changePlaybackRate() { + let currentPlaybackRateIndex = this.state + .getLatestValue() + .playbackRates.indexOf(this.currentPlaybackRate); + if (currentPlaybackRateIndex === -1) { + currentPlaybackRateIndex = 0; + } + const nextPlayBackIndex = + currentPlaybackRateIndex === this.playbackRates.length - 1 ? 0 : currentPlaybackRateIndex + 1; + const nextPlaybackRate = this.playbackRates[nextPlayBackIndex]; + this.state.partialNext({ + currentPlaybackRate: nextPlaybackRate, + }); + if (!this.playerRef) { + return; + } + if (this.playerRef?.setRateAsync) { + await this.playerRef.setRateAsync(nextPlaybackRate, true, 'high'); + } + } + + play() { + if (this.isPlaying) { + return; + } + + if (this._pool) { + this._pool.requestPlay(this.id); + } + + if (this.previewVoiceRecording) { + if (NativeHandlers.Audio?.resumePlayer) { + NativeHandlers.Audio.resumePlayer(); + } + this.state.partialNext({ + isPlaying: true, + }); + return; + } + + if (!this.playerRef) { + return; + } + + if (this.isExpoCLI) { + if (this.playerRef?.playAsync) { + this.playerRef.playAsync(); + } + } else { + if (this.playerRef?.resume) { + this.playerRef.resume(); + } + } + this.state.partialNext({ + isPlaying: true, + }); + } + + pause() { + if (!this.isPlaying) { + return; + } + if (this.previewVoiceRecording) { + if (NativeHandlers.Audio?.pausePlayer) { + NativeHandlers.Audio.pausePlayer(); + } + this.state.partialNext({ + isPlaying: false, + }); + return; + } + + if (!this.playerRef) { + return; + } + + if (this.isExpoCLI) { + if (this.playerRef?.pauseAsync) { + this.playerRef.pauseAsync(); + } + } else { + if (this.playerRef?.pause) { + this.playerRef.pause(); + } + } + this.state.partialNext({ + isPlaying: false, + }); + + if (this._pool) { + this._pool.notifyPaused(); + } + } + + toggle() { + if (this.isPlaying) { + this.pause(); + } else { + this.play(); + } + } + + async seek(positionInSeconds: number) { + if (this.previewVoiceRecording) { + this.position = positionInSeconds; + if (NativeHandlers.Audio?.seekToPlayer) { + NativeHandlers.Audio.seekToPlayer(positionInSeconds * 1000); + } + return; + } + if (!this.playerRef) { + return; + } + this.position = positionInSeconds; + if (this.isExpoCLI) { + if (positionInSeconds === 0) { + // If currentTime is 0, we should replay the video from 0th position. + if (this.playerRef?.replayAsync) { + await this.playerRef.replayAsync({}); + } + } else { + if (this.playerRef?.setPositionAsync) { + await this.playerRef.setPositionAsync(positionInSeconds); + } + } + } else { + if (this.playerRef?.seek) { + this.playerRef.seek(positionInSeconds); + } + } + } + + async stop() { + // First seek to 0 to stop the audio and then pause it + await this.seek(0); + this.pause(); + } + + onRemove() { + if (this.previewVoiceRecording) { + if (NativeHandlers.Audio?.stopPlayer) { + NativeHandlers.Audio.stopPlayer(); + } + this.state.partialNext({ + ...INITIAL_STATE, + currentPlaybackRate: this.playbackRates[0], + playbackRates: DEFAULT_PLAYBACK_RATES, + }); + return; + } + if (this.isExpoCLI) { + if (this.playerRef?.stopAsync && this.playerRef.unloadAsync) { + this.playerRef.stopAsync(); + this.playerRef.unloadAsync(); + } + } + this.playerRef = null; + this.state.partialNext({ + ...INITIAL_STATE, + currentPlaybackRate: this.playbackRates[0], + playbackRates: DEFAULT_PLAYBACK_RATES, + }); + } +} diff --git a/package/src/store/in-app-notifications-store.ts b/package/src/state-store/in-app-notifications-store.ts similarity index 100% rename from package/src/store/in-app-notifications-store.ts rename to package/src/state-store/in-app-notifications-store.ts diff --git a/package/src/state-store/index.ts b/package/src/state-store/index.ts new file mode 100644 index 000000000..642110a9c --- /dev/null +++ b/package/src/state-store/index.ts @@ -0,0 +1,3 @@ +export * from './audio-player'; +export * from './in-app-notifications-store'; +export * from './audio-player-pool'; diff --git a/package/src/store/index.ts b/package/src/store/index.ts deleted file mode 100644 index 1ab4b645f..000000000 --- a/package/src/store/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './in-app-notifications-store';