Skip to content

Commit 6041fa3

Browse files
authored
feat: introduce centralized audio playback (#2880)
1 parent a4f1fff commit 6041fa3

29 files changed

+2791
-347
lines changed

src/components/Attachment/Audio.tsx

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,48 +2,84 @@ import React from 'react';
22
import type { Attachment } from 'stream-chat';
33

44
import { DownloadButton, FileSizeIndicator, PlayButton, ProgressBar } from './components';
5-
import { useAudioController } from './hooks/useAudioController';
5+
import { type AudioPlayerState, useAudioPlayer } from '../AudioPlayback';
6+
import { useStateStore } from '../../store';
7+
import { useMessageContext } from '../../context';
8+
import type { AudioPlayer } from '../AudioPlayback/AudioPlayer';
69

7-
export type AudioProps = {
8-
// fixme: rename og to attachment
9-
og: Attachment;
10+
type AudioAttachmentUIProps = {
11+
audioPlayer: AudioPlayer;
1012
};
1113

12-
const UnMemoizedAudio = (props: AudioProps) => {
13-
const {
14-
og: { asset_url, file_size, mime_type, title },
15-
} = props;
16-
const { audioRef, isPlaying, progress, seek, togglePlay } = useAudioController({
17-
mimeType: mime_type,
18-
});
19-
20-
if (!asset_url) return null;
21-
14+
// todo: finish creating a BaseAudioPlayer derived from VoiceRecordingPlayerUI and AudioAttachmentUI
15+
const AudioAttachmentUI = ({ audioPlayer }: AudioAttachmentUIProps) => {
2216
const dataTestId = 'audio-widget';
2317
const rootClassName = 'str-chat__message-attachment-audio-widget';
2418

19+
const { isPlaying, progress } =
20+
useStateStore(audioPlayer?.state, audioPlayerStateSelector) ?? {};
21+
2522
return (
2623
<div className={rootClassName} data-testid={dataTestId}>
27-
<audio ref={audioRef}>
28-
<source data-testid='audio-source' src={asset_url} type='audio/mp3' />
29-
</audio>
3024
<div className='str-chat__message-attachment-audio-widget--play-controls'>
31-
<PlayButton isPlaying={isPlaying} onClick={togglePlay} />
25+
<PlayButton isPlaying={!!isPlaying} onClick={audioPlayer.togglePlay} />
3226
</div>
3327
<div className='str-chat__message-attachment-audio-widget--text'>
3428
<div className='str-chat__message-attachment-audio-widget--text-first-row'>
35-
<div className='str-chat__message-attachment-audio-widget--title'>{title}</div>
36-
<DownloadButton assetUrl={asset_url} />
29+
<div className='str-chat__message-attachment-audio-widget--title'>
30+
{audioPlayer.title}
31+
</div>
32+
<DownloadButton assetUrl={audioPlayer.src} />
3733
</div>
3834
<div className='str-chat__message-attachment-audio-widget--text-second-row'>
39-
<FileSizeIndicator fileSize={file_size} />
40-
<ProgressBar onClick={seek} progress={progress} />
35+
<FileSizeIndicator fileSize={audioPlayer.fileSize} />
36+
<ProgressBar onClick={audioPlayer.seek} progress={progress ?? 0} />
4137
</div>
4238
</div>
4339
</div>
4440
);
4541
};
4642

43+
export type AudioProps = {
44+
// fixme: rename og to attachment
45+
og: Attachment;
46+
};
47+
48+
const audioPlayerStateSelector = (state: AudioPlayerState) => ({
49+
isPlaying: state.isPlaying,
50+
progress: state.progressPercent,
51+
});
52+
53+
const UnMemoizedAudio = (props: AudioProps) => {
54+
const {
55+
og: { asset_url, file_size, mime_type, title },
56+
} = props;
57+
58+
/**
59+
* Introducing message context. This could be breaking change, therefore the fallback to {} is provided.
60+
* If this component is used outside the message context, then there will be no audio player namespacing
61+
* => scrolling away from the message in virtualized ML would create a new AudioPlayer instance.
62+
*
63+
* Edge case: the requester (message) has multiple attachments with the same assetURL - does not happen
64+
* with the default SDK components, but can be done with custom API calls.In this case all the Audio
65+
* widgets will share the state.
66+
*/
67+
const { message, threadList } = useMessageContext() ?? {};
68+
69+
const audioPlayer = useAudioPlayer({
70+
fileSize: file_size,
71+
mimeType: mime_type,
72+
requester:
73+
message?.id &&
74+
`${threadList ? (message.parent_id ?? message.id) : ''}${message.id}`,
75+
src: asset_url,
76+
title,
77+
waveformData: props.og.waveform_data,
78+
});
79+
80+
return audioPlayer ? <AudioAttachmentUI audioPlayer={audioPlayer} /> : null;
81+
};
82+
4783
/**
4884
* Audio attachment with play/pause button and progress bar
4985
*/

src/components/Attachment/Card.tsx

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import type { AudioProps } from './Audio';
66
import { ImageComponent } from '../Gallery';
77
import { SafeAnchor } from '../SafeAnchor';
88
import { PlayButton, ProgressBar } from './components';
9-
import { useAudioController } from './hooks/useAudioController';
109
import { useChannelStateContext } from '../../context/ChannelStateContext';
1110
import { useTranslationContext } from '../../context/TranslationContext';
1211

1312
import type { Attachment } from 'stream-chat';
1413
import type { RenderAttachmentProps } from './utils';
1514
import type { Dimensions } from '../../types/types';
15+
import { type AudioPlayerState, useAudioPlayer } from '../AudioPlayback';
16+
import { useStateStore } from '../../store';
17+
import { useMessageContext } from '../../context';
1618

1719
const getHostFromURL = (url?: string | null) => {
1820
if (url !== undefined && url !== null) {
@@ -126,31 +128,55 @@ const CardContent = (props: CardContentProps) => {
126128
);
127129
};
128130

131+
const audioPlayerStateSelector = (state: AudioPlayerState) => ({
132+
isPlaying: state.isPlaying,
133+
progress: state.progressPercent,
134+
});
135+
136+
const AudioWidget = ({ mimeType, src }: { src: string; mimeType?: string }) => {
137+
/**
138+
* Introducing message context. This could be breaking change, therefore the fallback to {} is provided.
139+
* If this component is used outside the message context, then there will be no audio player namespacing
140+
* => scrolling away from the message in virtualized ML would create a new AudioPlayer instance.
141+
*
142+
* Edge case: the requester (message) has multiple attachments with the same assetURL - does not happen
143+
* with the default SDK components, but can be done with custom API calls.In this case all the Audio
144+
* widgets will share the state.
145+
*/
146+
const { message, threadList } = useMessageContext() ?? {};
147+
148+
const audioPlayer = useAudioPlayer({
149+
mimeType,
150+
requester:
151+
message?.id &&
152+
`${threadList ? (message.parent_id ?? message.id) : ''}${message.id}`,
153+
src,
154+
});
155+
156+
const { isPlaying, progress } =
157+
useStateStore(audioPlayer?.state, audioPlayerStateSelector) ?? {};
158+
159+
if (!audioPlayer) return;
160+
161+
return (
162+
<div className='str-chat__message-attachment-card-audio-widget--first-row'>
163+
<div className='str-chat__message-attachment-audio-widget--play-controls'>
164+
<PlayButton isPlaying={!!isPlaying} onClick={audioPlayer.togglePlay} />
165+
</div>
166+
<ProgressBar onClick={audioPlayer.seek} progress={progress ?? 0} />
167+
</div>
168+
);
169+
};
170+
129171
export const CardAudio = ({
130172
og: { asset_url, author_name, mime_type, og_scrape_url, text, title, title_link },
131173
}: AudioProps) => {
132-
const { audioRef, isPlaying, progress, seek, togglePlay } = useAudioController({
133-
mimeType: mime_type,
134-
});
135-
136174
const url = title_link || og_scrape_url;
137175
const dataTestId = 'card-audio-widget';
138176
const rootClassName = 'str-chat__message-attachment-card-audio-widget';
139177
return (
140178
<div className={rootClassName} data-testid={dataTestId}>
141-
{asset_url && (
142-
<>
143-
<audio ref={audioRef}>
144-
<source data-testid='audio-source' src={asset_url} type='audio/mp3' />
145-
</audio>
146-
<div className='str-chat__message-attachment-card-audio-widget--first-row'>
147-
<div className='str-chat__message-attachment-audio-widget--play-controls'>
148-
<PlayButton isPlaying={isPlaying} onClick={togglePlay} />
149-
</div>
150-
<ProgressBar onClick={seek} progress={progress} />
151-
</div>
152-
</>
153-
)}
179+
{asset_url && <AudioWidget mimeType={mime_type} src={asset_url} />}
154180
<div className='str-chat__message-attachment-audio-widget--second-row'>
155181
{url && <SourceLink author_name={author_name} url={url} />}
156182
{title && (

src/components/Attachment/VoiceRecording.tsx

Lines changed: 78 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -7,95 +7,125 @@ import {
77
PlayButton,
88
WaveProgressBar,
99
} from './components';
10-
import { useAudioController } from './hooks/useAudioController';
1110
import { displayDuration } from './utils';
1211
import { FileIcon } from '../ReactFileUtilities';
13-
import { useTranslationContext } from '../../context';
12+
import { useMessageContext, useTranslationContext } from '../../context';
13+
import { type AudioPlayerState, useAudioPlayer } from '../AudioPlayback/';
14+
import { useStateStore } from '../../store';
15+
import type { AudioPlayer } from '../AudioPlayback/AudioPlayer';
1416

1517
const rootClassName = 'str-chat__message-attachment__voice-recording-widget';
1618

17-
export type VoiceRecordingPlayerProps = Pick<VoiceRecordingProps, 'attachment'> & {
18-
/** An array of fractional numeric values of playback speed to override the defaults (1.0, 1.5, 2.0) */
19-
playbackRates?: number[];
20-
};
21-
22-
export const VoiceRecordingPlayer = ({
23-
attachment,
24-
playbackRates,
25-
}: VoiceRecordingPlayerProps) => {
26-
const { t } = useTranslationContext('VoiceRecordingPlayer');
27-
const {
28-
asset_url,
29-
duration = 0,
30-
mime_type,
31-
title = t('Voice message'),
32-
waveform_data,
33-
} = attachment;
19+
const audioPlayerStateSelector = (state: AudioPlayerState) => ({
20+
canPlayRecord: state.canPlayRecord,
21+
isPlaying: state.isPlaying,
22+
playbackRate: state.currentPlaybackRate,
23+
progress: state.progressPercent,
24+
secondsElapsed: state.secondsElapsed,
25+
});
3426

35-
const {
36-
audioRef,
37-
increasePlaybackRate,
38-
isPlaying,
39-
playbackRate,
40-
progress,
41-
secondsElapsed,
42-
seek,
43-
togglePlay,
44-
} = useAudioController({
45-
durationSeconds: duration ?? 0,
46-
mimeType: mime_type,
47-
playbackRates,
48-
});
27+
type VoiceRecordingPlayerUIProps = {
28+
audioPlayer: AudioPlayer;
29+
};
4930

50-
if (!asset_url) return null;
31+
// todo: finish creating a BaseAudioPlayer derived from VoiceRecordingPlayerUI and AudioAttachmentUI
32+
const VoiceRecordingPlayerUI = ({ audioPlayer }: VoiceRecordingPlayerUIProps) => {
33+
const { canPlayRecord, isPlaying, playbackRate, progress, secondsElapsed } =
34+
useStateStore(audioPlayer?.state, audioPlayerStateSelector) ?? {};
5135

52-
const displayedDuration = secondsElapsed || duration;
36+
const displayedDuration = secondsElapsed || audioPlayer.durationSeconds;
5337

5438
return (
5539
<div className={rootClassName} data-testid='voice-recording-widget'>
56-
<audio ref={audioRef}>
57-
<source data-testid='audio-source' src={asset_url} type={mime_type} />
58-
</audio>
59-
<PlayButton isPlaying={isPlaying} onClick={togglePlay} />
40+
<PlayButton isPlaying={!!isPlaying} onClick={audioPlayer.togglePlay} />
6041
<div className='str-chat__message-attachment__voice-recording-widget__metadata'>
6142
<div
6243
className='str-chat__message-attachment__voice-recording-widget__title'
6344
data-testid='voice-recording-title'
64-
title={title}
45+
title={audioPlayer.title}
6546
>
66-
{title}
47+
{audioPlayer.title}
6748
</div>
6849
<div className='str-chat__message-attachment__voice-recording-widget__audio-state'>
6950
<div className='str-chat__message-attachment__voice-recording-widget__timer'>
70-
{attachment.duration ? (
51+
{audioPlayer.durationSeconds ? (
7152
displayDuration(displayedDuration)
7253
) : (
7354
<FileSizeIndicator
74-
fileSize={attachment.file_size}
55+
fileSize={audioPlayer.fileSize}
7556
maximumFractionDigits={0}
7657
/>
7758
)}
7859
</div>
7960
<WaveProgressBar
8061
progress={progress}
81-
seek={seek}
82-
waveformData={waveform_data || []}
62+
seek={audioPlayer.seek}
63+
waveformData={audioPlayer.waveformData || []}
8364
/>
8465
</div>
8566
</div>
8667
<div className='str-chat__message-attachment__voice-recording-widget__right-section'>
8768
{isPlaying ? (
88-
<PlaybackRateButton disabled={!audioRef.current} onClick={increasePlaybackRate}>
89-
{playbackRate.toFixed(1)}x
69+
<PlaybackRateButton
70+
disabled={!canPlayRecord}
71+
onClick={audioPlayer.increasePlaybackRate}
72+
>
73+
{playbackRate?.toFixed(1)}x
9074
</PlaybackRateButton>
9175
) : (
92-
<FileIcon big={true} mimeType={mime_type} size={40} />
76+
<FileIcon big={true} mimeType={audioPlayer.mimeType} size={40} />
9377
)}
9478
</div>
9579
</div>
9680
);
9781
};
9882

83+
export type VoiceRecordingPlayerProps = Pick<VoiceRecordingProps, 'attachment'> & {
84+
/** An array of fractional numeric values of playback speed to override the defaults (1.0, 1.5, 2.0) */
85+
playbackRates?: number[];
86+
};
87+
88+
export const VoiceRecordingPlayer = ({
89+
attachment,
90+
playbackRates,
91+
}: VoiceRecordingPlayerProps) => {
92+
const { t } = useTranslationContext();
93+
const {
94+
asset_url,
95+
duration = 0,
96+
file_size,
97+
mime_type,
98+
title = t('Voice message'),
99+
waveform_data,
100+
} = attachment;
101+
102+
/**
103+
* Introducing message context. This could be breaking change, therefore the fallback to {} is provided.
104+
* If this component is used outside the message context, then there will be no audio player namespacing
105+
* => scrolling away from the message in virtualized ML would create a new AudioPlayer instance.
106+
*
107+
* Edge case: the requester (message) has multiple attachments with the same assetURL - does not happen
108+
* with the default SDK components, but can be done with custom API calls.In this case all the Audio
109+
* widgets will share the state.
110+
*/
111+
const { message, threadList } = useMessageContext() ?? {};
112+
113+
const audioPlayer = useAudioPlayer({
114+
durationSeconds: duration ?? 0,
115+
fileSize: file_size,
116+
mimeType: mime_type,
117+
playbackRates,
118+
requester:
119+
message?.id &&
120+
`${threadList ? (message.parent_id ?? message.id) : ''}${message.id}`,
121+
src: asset_url,
122+
title,
123+
waveformData: waveform_data,
124+
});
125+
126+
return audioPlayer ? <VoiceRecordingPlayerUI audioPlayer={audioPlayer} /> : null;
127+
};
128+
99129
export type QuotedVoiceRecordingProps = Pick<VoiceRecordingProps, 'attachment'>;
100130

101131
export const QuotedVoiceRecording = ({ attachment }: QuotedVoiceRecordingProps) => {

0 commit comments

Comments
 (0)