Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 58 additions & 22 deletions src/components/Attachment/Audio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,48 +2,84 @@ import React from 'react';
import type { Attachment } from 'stream-chat';

import { DownloadButton, FileSizeIndicator, PlayButton, ProgressBar } from './components';
import { useAudioController } from './hooks/useAudioController';
import { type AudioPlayerState, useAudioPlayer } from '../AudioPlayback';
import { useStateStore } from '../../store';
import { useMessageContext } from '../../context';
import type { AudioPlayer } from '../AudioPlayback/AudioPlayer';

export type AudioProps = {
// fixme: rename og to attachment
og: Attachment;
type AudioAttachmentUIProps = {
audioPlayer: AudioPlayer;
};

const UnMemoizedAudio = (props: AudioProps) => {
const {
og: { asset_url, file_size, mime_type, title },
} = props;
const { audioRef, isPlaying, progress, seek, togglePlay } = useAudioController({
mimeType: mime_type,
});

if (!asset_url) return null;

// todo: finish creating a BaseAudioPlayer derived from VoiceRecordingPlayerUI and AudioAttachmentUI
const AudioAttachmentUI = ({ audioPlayer }: AudioAttachmentUIProps) => {
const dataTestId = 'audio-widget';
const rootClassName = 'str-chat__message-attachment-audio-widget';

const { isPlaying, progress } =
useStateStore(audioPlayer?.state, audioPlayerStateSelector) ?? {};

return (
<div className={rootClassName} data-testid={dataTestId}>
<audio ref={audioRef}>
<source data-testid='audio-source' src={asset_url} type='audio/mp3' />
</audio>
<div className='str-chat__message-attachment-audio-widget--play-controls'>
<PlayButton isPlaying={isPlaying} onClick={togglePlay} />
<PlayButton isPlaying={!!isPlaying} onClick={audioPlayer.togglePlay} />
</div>
<div className='str-chat__message-attachment-audio-widget--text'>
<div className='str-chat__message-attachment-audio-widget--text-first-row'>
<div className='str-chat__message-attachment-audio-widget--title'>{title}</div>
<DownloadButton assetUrl={asset_url} />
<div className='str-chat__message-attachment-audio-widget--title'>
{audioPlayer.title}
</div>
<DownloadButton assetUrl={audioPlayer.src} />
</div>
<div className='str-chat__message-attachment-audio-widget--text-second-row'>
<FileSizeIndicator fileSize={file_size} />
<ProgressBar onClick={seek} progress={progress} />
<FileSizeIndicator fileSize={audioPlayer.fileSize} />
<ProgressBar onClick={audioPlayer.seek} progress={progress ?? 0} />
</div>
</div>
</div>
);
};

export type AudioProps = {
// fixme: rename og to attachment
og: Attachment;
};

const audioPlayerStateSelector = (state: AudioPlayerState) => ({
isPlaying: state.isPlaying,
progress: state.progressPercent,
});

const UnMemoizedAudio = (props: AudioProps) => {
const {
og: { asset_url, file_size, mime_type, title },
} = props;

/**
* Introducing message context. This could be breaking change, therefore the fallback to {} is provided.
* If this component is used outside the message context, then there will be no audio player namespacing
* => scrolling away from the message in virtualized ML would create a new AudioPlayer instance.
*
* Edge case: the requester (message) has multiple attachments with the same assetURL - does not happen
* with the default SDK components, but can be done with custom API calls.In this case all the Audio
* widgets will share the state.
*/
const { message, threadList } = useMessageContext() ?? {};

const audioPlayer = useAudioPlayer({
fileSize: file_size,
mimeType: mime_type,
requester:
message?.id &&
`${threadList ? (message.parent_id ?? message.id) : ''}${message.id}`,
src: asset_url,
title,
waveformData: props.og.waveform_data,
});

return audioPlayer ? <AudioAttachmentUI audioPlayer={audioPlayer} /> : null;
};

/**
* Audio attachment with play/pause button and progress bar
*/
Expand Down
62 changes: 44 additions & 18 deletions src/components/Attachment/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import type { AudioProps } from './Audio';
import { ImageComponent } from '../Gallery';
import { SafeAnchor } from '../SafeAnchor';
import { PlayButton, ProgressBar } from './components';
import { useAudioController } from './hooks/useAudioController';
import { useChannelStateContext } from '../../context/ChannelStateContext';
import { useTranslationContext } from '../../context/TranslationContext';

import type { Attachment } from 'stream-chat';
import type { RenderAttachmentProps } from './utils';
import type { Dimensions } from '../../types/types';
import { type AudioPlayerState, useAudioPlayer } from '../AudioPlayback';
import { useStateStore } from '../../store';
import { useMessageContext } from '../../context';

const getHostFromURL = (url?: string | null) => {
if (url !== undefined && url !== null) {
Expand Down Expand Up @@ -126,31 +128,55 @@ const CardContent = (props: CardContentProps) => {
);
};

const audioPlayerStateSelector = (state: AudioPlayerState) => ({
isPlaying: state.isPlaying,
progress: state.progressPercent,
});

const AudioWidget = ({ mimeType, src }: { src: string; mimeType?: string }) => {
/**
* Introducing message context. This could be breaking change, therefore the fallback to {} is provided.
* If this component is used outside the message context, then there will be no audio player namespacing
* => scrolling away from the message in virtualized ML would create a new AudioPlayer instance.
*
* Edge case: the requester (message) has multiple attachments with the same assetURL - does not happen
* with the default SDK components, but can be done with custom API calls.In this case all the Audio
* widgets will share the state.
*/
const { message, threadList } = useMessageContext() ?? {};

const audioPlayer = useAudioPlayer({
mimeType,
requester:
message?.id &&
`${threadList ? (message.parent_id ?? message.id) : ''}${message.id}`,
src,
});

const { isPlaying, progress } =
useStateStore(audioPlayer?.state, audioPlayerStateSelector) ?? {};

if (!audioPlayer) return;

return (
<div className='str-chat__message-attachment-card-audio-widget--first-row'>
<div className='str-chat__message-attachment-audio-widget--play-controls'>
<PlayButton isPlaying={!!isPlaying} onClick={audioPlayer.togglePlay} />
</div>
<ProgressBar onClick={audioPlayer.seek} progress={progress ?? 0} />
</div>
);
};

export const CardAudio = ({
og: { asset_url, author_name, mime_type, og_scrape_url, text, title, title_link },
}: AudioProps) => {
const { audioRef, isPlaying, progress, seek, togglePlay } = useAudioController({
mimeType: mime_type,
});

const url = title_link || og_scrape_url;
const dataTestId = 'card-audio-widget';
const rootClassName = 'str-chat__message-attachment-card-audio-widget';
return (
<div className={rootClassName} data-testid={dataTestId}>
{asset_url && (
<>
<audio ref={audioRef}>
<source data-testid='audio-source' src={asset_url} type='audio/mp3' />
</audio>
<div className='str-chat__message-attachment-card-audio-widget--first-row'>
<div className='str-chat__message-attachment-audio-widget--play-controls'>
<PlayButton isPlaying={isPlaying} onClick={togglePlay} />
</div>
<ProgressBar onClick={seek} progress={progress} />
</div>
</>
)}
{asset_url && <AudioWidget mimeType={mime_type} src={asset_url} />}
<div className='str-chat__message-attachment-audio-widget--second-row'>
{url && <SourceLink author_name={author_name} url={url} />}
{title && (
Expand Down
126 changes: 78 additions & 48 deletions src/components/Attachment/VoiceRecording.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,95 +7,125 @@ import {
PlayButton,
WaveProgressBar,
} from './components';
import { useAudioController } from './hooks/useAudioController';
import { displayDuration } from './utils';
import { FileIcon } from '../ReactFileUtilities';
import { useTranslationContext } from '../../context';
import { useMessageContext, useTranslationContext } from '../../context';
import { type AudioPlayerState, useAudioPlayer } from '../AudioPlayback/';
import { useStateStore } from '../../store';
import type { AudioPlayer } from '../AudioPlayback/AudioPlayer';

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

export type VoiceRecordingPlayerProps = Pick<VoiceRecordingProps, 'attachment'> & {
/** An array of fractional numeric values of playback speed to override the defaults (1.0, 1.5, 2.0) */
playbackRates?: number[];
};

export const VoiceRecordingPlayer = ({
attachment,
playbackRates,
}: VoiceRecordingPlayerProps) => {
const { t } = useTranslationContext('VoiceRecordingPlayer');
const {
asset_url,
duration = 0,
mime_type,
title = t('Voice message'),
waveform_data,
} = attachment;
const audioPlayerStateSelector = (state: AudioPlayerState) => ({
canPlayRecord: state.canPlayRecord,
isPlaying: state.isPlaying,
playbackRate: state.currentPlaybackRate,
progress: state.progressPercent,
secondsElapsed: state.secondsElapsed,
});

const {
audioRef,
increasePlaybackRate,
isPlaying,
playbackRate,
progress,
secondsElapsed,
seek,
togglePlay,
} = useAudioController({
durationSeconds: duration ?? 0,
mimeType: mime_type,
playbackRates,
});
type VoiceRecordingPlayerUIProps = {
audioPlayer: AudioPlayer;
};

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

const displayedDuration = secondsElapsed || duration;
const displayedDuration = secondsElapsed || audioPlayer.durationSeconds;

return (
<div className={rootClassName} data-testid='voice-recording-widget'>
<audio ref={audioRef}>
<source data-testid='audio-source' src={asset_url} type={mime_type} />
</audio>
<PlayButton isPlaying={isPlaying} onClick={togglePlay} />
<PlayButton isPlaying={!!isPlaying} onClick={audioPlayer.togglePlay} />
<div className='str-chat__message-attachment__voice-recording-widget__metadata'>
<div
className='str-chat__message-attachment__voice-recording-widget__title'
data-testid='voice-recording-title'
title={title}
title={audioPlayer.title}
>
{title}
{audioPlayer.title}
</div>
<div className='str-chat__message-attachment__voice-recording-widget__audio-state'>
<div className='str-chat__message-attachment__voice-recording-widget__timer'>
{attachment.duration ? (
{audioPlayer.durationSeconds ? (
displayDuration(displayedDuration)
) : (
<FileSizeIndicator
fileSize={attachment.file_size}
fileSize={audioPlayer.fileSize}
maximumFractionDigits={0}
/>
)}
</div>
<WaveProgressBar
progress={progress}
seek={seek}
waveformData={waveform_data || []}
seek={audioPlayer.seek}
waveformData={audioPlayer.waveformData || []}
/>
</div>
</div>
<div className='str-chat__message-attachment__voice-recording-widget__right-section'>
{isPlaying ? (
<PlaybackRateButton disabled={!audioRef.current} onClick={increasePlaybackRate}>
{playbackRate.toFixed(1)}x
<PlaybackRateButton
disabled={!canPlayRecord}
onClick={audioPlayer.increasePlaybackRate}
>
{playbackRate?.toFixed(1)}x
</PlaybackRateButton>
) : (
<FileIcon big={true} mimeType={mime_type} size={40} />
<FileIcon big={true} mimeType={audioPlayer.mimeType} size={40} />
)}
</div>
</div>
);
};

export type VoiceRecordingPlayerProps = Pick<VoiceRecordingProps, 'attachment'> & {
/** An array of fractional numeric values of playback speed to override the defaults (1.0, 1.5, 2.0) */
playbackRates?: number[];
};

export const VoiceRecordingPlayer = ({
attachment,
playbackRates,
}: VoiceRecordingPlayerProps) => {
const { t } = useTranslationContext();
const {
asset_url,
duration = 0,
file_size,
mime_type,
title = t('Voice message'),
waveform_data,
} = attachment;

/**
* Introducing message context. This could be breaking change, therefore the fallback to {} is provided.
* If this component is used outside the message context, then there will be no audio player namespacing
* => scrolling away from the message in virtualized ML would create a new AudioPlayer instance.
*
* Edge case: the requester (message) has multiple attachments with the same assetURL - does not happen
* with the default SDK components, but can be done with custom API calls.In this case all the Audio
* widgets will share the state.
*/
const { message, threadList } = useMessageContext() ?? {};

const audioPlayer = useAudioPlayer({
durationSeconds: duration ?? 0,
fileSize: file_size,
mimeType: mime_type,
playbackRates,
requester:
message?.id &&
`${threadList ? (message.parent_id ?? message.id) : ''}${message.id}`,
src: asset_url,
title,
waveformData: waveform_data,
});

return audioPlayer ? <VoiceRecordingPlayerUI audioPlayer={audioPlayer} /> : null;
};

export type QuotedVoiceRecordingProps = Pick<VoiceRecordingProps, 'attachment'>;

export const QuotedVoiceRecording = ({ attachment }: QuotedVoiceRecordingProps) => {
Expand Down
Loading