From e889046de5d827f050c52ba3aeeb56193a35f260 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 4 Nov 2025 12:40:23 +0100 Subject: [PATCH 01/15] feat: play the audio recordings outside and independently of message lists --- src/components/Attachment/Audio.tsx | 38 ++- src/components/Attachment/Card.tsx | 61 +++- src/components/Attachment/VoiceRecording.tsx | 57 ++-- .../Attachment/__tests__/Audio.test.js | 229 ++++++------- .../Attachment/__tests__/Card.test.js | 5 +- .../__tests__/VoiceRecording.test.js | 26 +- .../__tests__/__snapshots__/Card.test.js.snap | 42 --- .../Attachment/hooks/useAudioController.ts | 1 + src/components/AudioPlayer/AudioPlayer.ts | 314 +++++++++++++++++ src/components/AudioPlayer/AudioPlayerPool.ts | 123 +++++++ .../AudioPlayer/WithAudioPlayback.tsx | 82 +++++ .../AudioPlayer/__tests__/AudioPlayer.test.js | 323 ++++++++++++++++++ .../__tests__/WithAudioPlayback.test.js | 321 +++++++++++++++++ src/components/AudioPlayer/index.ts | 6 + .../plugins/AudioPlayerNotificationsPlugin.ts | 41 +++ .../AudioPlayer/plugins/AudioPlayerPlugin.ts | 13 + .../AudioPlayerNotificationsPlugin.test.js | 59 ++++ src/components/Channel/Channel.tsx | 5 +- .../AudioRecorder/AudioRecordingPreview.tsx | 37 +- .../__tests__/AudioRecordingPreview.test.js | 57 +++- .../__snapshots__/AudioRecorder.test.js.snap | 6 - .../VoiceRecordingPreview.tsx | 27 +- src/components/index.ts | 3 +- .../NotificationTranslationTopic.ts | 2 + .../browserAudioPlaybackError.ts | 7 + 25 files changed, 1630 insertions(+), 255 deletions(-) create mode 100644 src/components/AudioPlayer/AudioPlayer.ts create mode 100644 src/components/AudioPlayer/AudioPlayerPool.ts create mode 100644 src/components/AudioPlayer/WithAudioPlayback.tsx create mode 100644 src/components/AudioPlayer/__tests__/AudioPlayer.test.js create mode 100644 src/components/AudioPlayer/__tests__/WithAudioPlayback.test.js create mode 100644 src/components/AudioPlayer/index.ts create mode 100644 src/components/AudioPlayer/plugins/AudioPlayerNotificationsPlugin.ts create mode 100644 src/components/AudioPlayer/plugins/AudioPlayerPlugin.ts create mode 100644 src/components/AudioPlayer/plugins/__tests__/AudioPlayerNotificationsPlugin.test.js create mode 100644 src/i18n/TranslationBuilder/notifications/browserAudioPlaybackError.ts diff --git a/src/components/Attachment/Audio.tsx b/src/components/Attachment/Audio.tsx index a6e4fc71d8..fec9637926 100644 --- a/src/components/Attachment/Audio.tsx +++ b/src/components/Attachment/Audio.tsx @@ -2,33 +2,55 @@ import React from 'react'; import type { Attachment } from 'stream-chat'; import { DownloadButton, FileSizeIndicator, PlayButton, ProgressBar } from './components'; -import { useAudioController } from './hooks/useAudioController'; +import { useAudioPlayer } from '../AudioPlayer/WithAudioPlayback'; +import type { AudioPlayerState } from '../AudioPlayer/AudioPlayer'; +import { useStateStore } from '../../store'; +import { useMessageContext } from '../../context'; 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; - const { audioRef, isPlaying, progress, seek, togglePlay } = useAudioController({ + + /** + * 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 } = useMessageContext() ?? {}; + + const audioPlayer = useAudioPlayer({ mimeType: mime_type, + requester: message?.id && `${message.parent_id}${message.id}`, + src: asset_url, }); - if (!asset_url) return null; + const { isPlaying, progress } = + useStateStore(audioPlayer?.state, audioPlayerStateSelector) ?? {}; + + if (!audioPlayer) return null; const dataTestId = 'audio-widget'; const rootClassName = 'str-chat__message-attachment-audio-widget'; return (
-
- +
@@ -37,7 +59,7 @@ const UnMemoizedAudio = (props: AudioProps) => {
- +
diff --git a/src/components/Attachment/Card.tsx b/src/components/Attachment/Card.tsx index f5d2419d1b..d3658ec942 100644 --- a/src/components/Attachment/Card.tsx +++ b/src/components/Attachment/Card.tsx @@ -6,13 +6,16 @@ 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 { useAudioPlayer } from '../AudioPlayer/WithAudioPlayback'; +import { useStateStore } from '../../store'; +import type { AudioPlayerState } from '../AudioPlayer/AudioPlayer'; +import { useMessageContext } from '../../context'; const getHostFromURL = (url?: string | null) => { if (url !== undefined && url !== null) { @@ -126,31 +129,53 @@ 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 } = useMessageContext() ?? {}; + + const audioPlayer = useAudioPlayer({ + mimeType, + requester: message?.id && `${message.parent_id}${message.id}`, + src, + }); + + const { isPlaying, progress } = + useStateStore(audioPlayer?.state, audioPlayerStateSelector) ?? {}; + + if (!audioPlayer) return; + + return ( +
+
+ +
+ +
+ ); +}; + 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 (
- {asset_url && ( - <> - -
-
- -
- -
- - )} + {asset_url && }
{url && } {title && ( diff --git a/src/components/Attachment/VoiceRecording.tsx b/src/components/Attachment/VoiceRecording.tsx index 7800207268..cdef4a2dcf 100644 --- a/src/components/Attachment/VoiceRecording.tsx +++ b/src/components/Attachment/VoiceRecording.tsx @@ -7,13 +7,23 @@ 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 { useAudioPlayer } from '../AudioPlayer/WithAudioPlayback'; +import { useStateStore } from '../../store'; +import type { AudioPlayerState } from '../AudioPlayer/AudioPlayer'; const rootClassName = 'str-chat__message-attachment__voice-recording-widget'; +const audioPlayerStateSelector = (state: AudioPlayerState) => ({ + canPlayRecord: state.canPlayRecord, + isPlaying: state.isPlaying, + playbackRate: state.currentPlaybackRate, + progress: state.progressPercent, + secondsElapsed: state.secondsElapsed, +}); + export type VoiceRecordingPlayerProps = Pick & { /** An array of fractional numeric values of playback speed to override the defaults (1.0, 1.5, 2.0) */ playbackRates?: number[]; @@ -32,31 +42,35 @@ export const VoiceRecordingPlayer = ({ waveform_data, } = attachment; - const { - audioRef, - increasePlaybackRate, - isPlaying, - playbackRate, - progress, - secondsElapsed, - seek, - togglePlay, - } = useAudioController({ + /** + * 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 } = useMessageContext() ?? {}; + + const audioPlayer = useAudioPlayer({ durationSeconds: duration ?? 0, mimeType: mime_type, playbackRates, + requester: message?.id && `${message.parent_id}${message.id}`, + src: asset_url, }); - if (!asset_url) return null; + const { canPlayRecord, isPlaying, playbackRate, progress, secondsElapsed } = + useStateStore(audioPlayer?.state, audioPlayerStateSelector) ?? {}; + + if (!audioPlayer) return null; const displayedDuration = secondsElapsed || duration; return (
- - +
{isPlaying ? ( - - {playbackRate.toFixed(1)}x + + {playbackRate?.toFixed(1)}x ) : ( diff --git a/src/components/Attachment/__tests__/Audio.test.js b/src/components/Attachment/__tests__/Audio.test.js index b88b201091..a7fa937a70 100644 --- a/src/components/Attachment/__tests__/Audio.test.js +++ b/src/components/Attachment/__tests__/Audio.test.js @@ -3,13 +3,31 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra import '@testing-library/jest-dom'; import { Audio } from '../Audio'; - -import { ChannelActionProvider } from '../../../context'; import { generateAudioAttachment } from '../../../mock-builders'; import { prettifyFileSize } from '../../MessageInput/hooks/utils'; - -const AUDIO = generateAudioAttachment(); -const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +import { WithAudioPlayback } from '../../AudioPlayer/WithAudioPlayback'; + +jest.mock('../../../context/ChatContext', () => ({ + useChatContext: () => ({ client: mockClient }), +})); +jest.mock('../../../context/TranslationContext', () => ({ + useTranslationContext: () => ({ t: (s) => tSpy(s) }), +})); + +const addErrorSpy = jest.fn(); +const mockClient = { + notifications: { addError: addErrorSpy }, +}; +const tSpy = (s) => s; + +// capture created Audio() elements so we can assert src & dispatch events +const createdAudios = []; //HTMLAudioElement[] +const RealAudio = window.Audio; +jest.spyOn(window, 'Audio').mockImplementation(function AudioMock(...args) { + const el = new RealAudio(...args); + createdAudios.push(el); + return el; +}); const originalConsoleError = console.error; jest.spyOn(console, 'error').mockImplementationOnce((...errorOrTextorArg) => { @@ -20,51 +38,62 @@ jest.spyOn(console, 'error').mockImplementationOnce((...errorOrTextorArg) => { originalConsoleError(...errorOrTextorArg); }); -const addNotificationSpy = jest.fn(); -const defaultChannelActionContext = { addNotification: addNotificationSpy }; +const audioAttachment = generateAudioAttachment({ mime_type: undefined }); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const renderComponent = ( props = { - channelActionContext: defaultChannelActionContext, - og: AUDIO, + og: audioAttachment, }, ) => render( - + , + , ); -const playButtonTestId = 'play-audio'; -const pauseButtonTestId = 'pause-audio'; -const playButton = () => screen.queryByTestId(playButtonTestId); -const pauseButton = () => screen.queryByTestId(pauseButtonTestId); +const playButton = () => screen.queryByTestId('play-audio'); +const pauseButton = () => screen.queryByTestId('pause-audio'); + +const expectAddErrorMessage = (message) => { + expect(addErrorSpy).toHaveBeenCalled(); + const hit = addErrorSpy.mock.calls.find((c) => c?.[0]?.message === message); + expect(hit).toBeTruthy(); +}; describe('Audio', () => { - beforeAll(() => { + beforeEach(() => { // jsdom doesn't define these, so mock them instead // see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement#Methods jest.spyOn(HTMLMediaElement.prototype, 'play').mockImplementation(() => {}); jest.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {}); + jest.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => {}); }); + afterEach(() => { cleanup(); - jest.resetAllMocks(); + jest.clearAllMocks(); + createdAudios.length = 0; }); - it('should render title and file size', () => { + it('renders title and file size', () => { const { container, getByText } = renderComponent({ - og: AUDIO, + og: audioAttachment, }); - expect(getByText(AUDIO.title)).toBeInTheDocument(); - expect(getByText(prettifyFileSize(AUDIO.file_size))).toBeInTheDocument(); + expect(getByText(audioAttachment.title)).toBeInTheDocument(); + expect(getByText(prettifyFileSize(audioAttachment.file_size))).toBeInTheDocument(); expect(container.querySelector('img')).not.toBeInTheDocument(); }); - it('should show the correct progress after clicking to the middle of a progress bar (seeking)', async () => { - const { getByTestId } = renderComponent({ og: AUDIO }); + it('creates a playback Audio() with the right src', () => { + renderComponent({ og: audioAttachment }); + expect(createdAudios.length).toBe(1); + expect(createdAudios[0].src).toBe(audioAttachment.asset_url); + }); + + it('shows the correct progress after clicking to the middle of a progress bar (seeking)', async () => { + const { getByTestId } = renderComponent({ og: audioAttachment }); jest .spyOn(HTMLDivElement.prototype, 'getBoundingClientRect') @@ -86,84 +115,62 @@ describe('Audio', () => { }); }); - it('should render an audio element with the right source', () => { - const { getByTestId } = renderComponent({ og: AUDIO }); + it('shows the correct button if the song is paused/playing', async () => { + renderComponent({ og: { ...audioAttachment } }); - const source = getByTestId('audio-source'); + const audioPausedMock = jest.spyOn(createdAudios[0], 'paused', 'get'); - expect(source).toBeInTheDocument(); - expect(source.src).toBe(AUDIO.asset_url); - expect(source.parentElement).toBeInstanceOf(HTMLAudioElement); - }); - - it('should show the correct button if the song is paused/playing', async () => { - const { container } = renderComponent({ - og: { ...AUDIO, mime_type: undefined }, - }); - const audioPausedMock = jest.spyOn(container.querySelector('audio'), 'paused', 'get'); - expect(await playButton()).toBeInTheDocument(); - expect(await pauseButton()).not.toBeInTheDocument(); + expect(playButton()).toBeInTheDocument(); - audioPausedMock.mockReturnValueOnce(true); await act(async () => { await fireEvent.click(playButton()); }); - - expect(await playButton()).not.toBeInTheDocument(); - expect(await pauseButton()).toBeInTheDocument(); + expect(pauseButton()).toBeInTheDocument(); audioPausedMock.mockReturnValueOnce(false); await act(async () => { await fireEvent.click(pauseButton()); }); + expect(playButton()).toBeInTheDocument(); - expect(await playButton()).toBeInTheDocument(); - expect(await pauseButton()).not.toBeInTheDocument(); - expect(addNotificationSpy).not.toHaveBeenCalled(); + expect(addErrorSpy).not.toHaveBeenCalled(); audioPausedMock.mockRestore(); }); - it('should pause the audio if the playback has not started in 2000ms', async () => { - jest.useFakeTimers('modern'); - const { container } = renderComponent({ - og: { ...AUDIO, mime_type: undefined }, + it('pauses the audio if the playback has not started in 2000ms', async () => { + jest.useFakeTimers({ now: Date.now() }); + renderComponent({ + og: audioAttachment, }); + const audio = createdAudios[0]; + audio.play.mockImplementationOnce(() => sleep(3000)); - const audio = container.querySelector('audio'); - const audioPlayMock = jest.spyOn(audio, 'play').mockImplementation(() => delay(3000)); - const audioPauseMock = jest.spyOn(audio, 'pause'); - - expect(await playButton()).toBeInTheDocument(); - expect(await pauseButton()).not.toBeInTheDocument(); + expect(playButton()).toBeInTheDocument(); + expect(pauseButton()).not.toBeInTheDocument(); await act(async () => { await fireEvent.click(playButton()); }); - expect(await playButton()).toBeInTheDocument(); - expect(await pauseButton()).not.toBeInTheDocument(); + expect(playButton()).toBeInTheDocument(); + expect(pauseButton()).not.toBeInTheDocument(); jest.advanceTimersByTime(2000); - await waitFor(async () => { - expect(audioPauseMock).toHaveBeenCalledWith(); - expect(await playButton()).toBeInTheDocument(); - expect(await pauseButton()).not.toBeInTheDocument(); - expect(addNotificationSpy).not.toHaveBeenCalled(); + await waitFor(() => { + expect(playButton()).toBeInTheDocument(); + expect(pauseButton()).not.toBeInTheDocument(); + expect(addErrorSpy).not.toHaveBeenCalled(); }); jest.useRealTimers(); - audioPlayMock.mockRestore(); - audioPauseMock.mockRestore(); }); - it('should register error if pausing the audio after 2000ms of inactivity failed', async () => { + it('registers error if pausing the audio after 2000ms of inactivity failed', async () => { jest.useFakeTimers('modern'); - const { container } = renderComponent({ - og: { ...AUDIO, mime_type: undefined }, - }); - const audio = container.querySelector('audio'); - const audioPlayMock = jest.spyOn(audio, 'play').mockImplementation(() => delay(3000)); - const audioPauseMock = jest.spyOn(audio, 'pause').mockImplementationOnce(() => { + renderComponent({ og: audioAttachment }); + const audio = createdAudios[0]; + audio.play.mockImplementationOnce(() => sleep(3000)); + audio.pause.mockImplementationOnce(() => { throw new Error(''); }); @@ -172,81 +179,65 @@ describe('Audio', () => { }); jest.advanceTimersByTime(2000); await waitFor(() => { - expect(audioPauseMock).toHaveBeenCalledWith(); - expect(addNotificationSpy).toHaveBeenCalledWith( - 'Failed to play the recording', - 'error', - ); + expect(audio.pause).toHaveBeenCalledWith(); + expectAddErrorMessage('Failed to play the recording'); }); jest.useRealTimers(); - audioPlayMock.mockRestore(); - audioPauseMock.mockRestore(); }); - it('should register error if playing the audio failed', async () => { + it('registers error if playing the audio failed', async () => { const errorText = 'Test error'; - const { container } = renderComponent({ - og: AUDIO, + renderComponent({ + og: audioAttachment, }); - const audio = container.querySelector('audio'); - const audioPlayMock = jest - .spyOn(audio, 'play') - .mockRejectedValueOnce(new Error(errorText)); + const audio = createdAudios[0]; + audio.play.mockRejectedValueOnce(new Error(errorText)); const audioCanPlayTypeMock = jest .spyOn(audio, 'canPlayType') .mockReturnValue('maybe'); - expect(await playButton()).toBeInTheDocument(); - expect(await pauseButton()).not.toBeInTheDocument(); + expect(playButton()).toBeInTheDocument(); + expect(pauseButton()).not.toBeInTheDocument(); await act(async () => { await fireEvent.click(playButton()); }); - expect(await playButton()).toBeInTheDocument(); - expect(await pauseButton()).not.toBeInTheDocument(); - expect(addNotificationSpy).toHaveBeenCalledWith(errorText, 'error'); - audioPlayMock.mockRestore(); + expect(playButton()).toBeInTheDocument(); + expect(pauseButton()).not.toBeInTheDocument(); + expectAddErrorMessage(errorText); audioCanPlayTypeMock.mockRestore(); }); it('should register error if the audio MIME type is not playable', async () => { - const { container } = renderComponent({ - og: AUDIO, - }); - const audio = container.querySelector('audio'); - const audioPlayMock = jest.spyOn(audio, 'play'); + renderComponent({ og: { ...audioAttachment, mime_type: 'audio/mp4' } }); + const audio = createdAudios[0]; const audioCanPlayTypeMock = jest.spyOn(audio, 'canPlayType').mockReturnValue(''); - expect(await playButton()).toBeInTheDocument(); - expect(await pauseButton()).not.toBeInTheDocument(); + expect(audio.play).not.toHaveBeenCalled(); + + expect(playButton()).toBeInTheDocument(); + expect(pauseButton()).not.toBeInTheDocument(); await act(async () => { await fireEvent.click(playButton()); }); - expect(audioPlayMock).not.toHaveBeenCalled(); - expect(addNotificationSpy).toHaveBeenCalledWith( - 'Recording format is not supported and cannot be reproduced', - 'error', - ); - expect(await playButton()).toBeInTheDocument(); - expect(await pauseButton()).not.toBeInTheDocument(); - - audioPlayMock.mockRestore(); + expect(audio.play).not.toHaveBeenCalled(); + expect(playButton()).toBeInTheDocument(); + expect(pauseButton()).not.toBeInTheDocument(); + expectAddErrorMessage('Recording format is not supported and cannot be reproduced'); + audioCanPlayTypeMock.mockRestore(); }); - it('should show the correct progress', async () => { - const { container } = renderComponent({ og: AUDIO }); + it('shows the correct progress on timeupdate', async () => { + renderComponent({ og: audioAttachment }); - jest - .spyOn(HTMLAudioElement.prototype, 'duration', 'get') - .mockImplementationOnce(() => 100); - jest - .spyOn(HTMLAudioElement.prototype, 'currentTime', 'get') - .mockImplementationOnce(() => 50); - const audioElement = container.querySelector('audio'); - fireEvent.timeUpdate(audioElement); + const audio = createdAudios[0]; + jest.spyOn(audio, 'duration', 'get').mockReturnValue(100); + jest.spyOn(audio, 'currentTime', 'get').mockReturnValue(50); + + audio.dispatchEvent(new Event('timeupdate')); await waitFor(() => { expect(screen.getByTestId('audio-progress')).toHaveAttribute('data-progress', '50'); diff --git a/src/components/Attachment/__tests__/Card.test.js b/src/components/Attachment/__tests__/Card.test.js index 5582dd599f..d3fbf1bbab 100644 --- a/src/components/Attachment/__tests__/Card.test.js +++ b/src/components/Attachment/__tests__/Card.test.js @@ -19,6 +19,7 @@ import { mockTranslationContext, useMockedApis, } from '../../../mock-builders'; +import { WithAudioPlayback } from '../../AudioPlayer'; let chatClient; let channel; @@ -41,7 +42,9 @@ const renderCard = ({ cardProps, chatContext, theRenderer = render }) => - + + + diff --git a/src/components/Attachment/__tests__/VoiceRecording.test.js b/src/components/Attachment/__tests__/VoiceRecording.test.js index fb145257f5..fd4dc3905e 100644 --- a/src/components/Attachment/__tests__/VoiceRecording.test.js +++ b/src/components/Attachment/__tests__/VoiceRecording.test.js @@ -6,6 +6,7 @@ import { generateVoiceRecordingAttachment } from '../../../mock-builders'; import { VoiceRecording, VoiceRecordingPlayer } from '../VoiceRecording'; import { ChannelActionProvider } from '../../../context'; import { ResizeObserverMock } from '../../../mock-builders/browser'; +import { WithAudioPlayback } from '../../AudioPlayer'; const AUDIO_RECORDING_PLAYER_TEST_ID = 'voice-recording-widget'; const QUOTED_AUDIO_RECORDING_TEST_ID = 'quoted-voice-recording-widget'; @@ -33,7 +34,9 @@ const addNotificationSpy = jest.fn(); const renderComponent = (props, VoiceRecordingComponent = VoiceRecording) => render( - + + + , ); @@ -56,7 +59,7 @@ describe('VoiceRecordingPlayer', () => { jest.spyOn(window.HTMLMediaElement.prototype, 'play').mockImplementation(() => {}); jest.spyOn(window.HTMLMediaElement.prototype, 'canPlayType').mockReturnValue('maybe'); }); - afterAll(jest.restoreAllMocks); + afterAll(jest.clearAllMocks); it('should not render the component if asset_url is missing', () => { const { container } = renderComponent({ @@ -133,7 +136,18 @@ describe('VoiceRecordingPlayer', () => { }); it('should show the correct progress', async () => { - const { container } = renderComponent({ attachment }); + const createdAudios = []; // HTMLAudioElement[] + + const RealAudio = window.Audio; + const constructorSpy = jest + .spyOn(window, 'Audio') + .mockImplementation(function AudioMock(...args) { + const el = new RealAudio(...args); + createdAudios.push(el); + return el; + }); + + renderComponent({ attachment }); jest .spyOn(HTMLAudioElement.prototype, 'duration', 'get') @@ -141,14 +155,16 @@ describe('VoiceRecordingPlayer', () => { jest .spyOn(HTMLAudioElement.prototype, 'currentTime', 'get') .mockImplementationOnce(() => 50); - const audioElement = container.querySelector('audio'); - fireEvent.timeUpdate(audioElement); + expect(createdAudios.length).toBe(1); + fireEvent.timeUpdate(createdAudios[0]); await waitFor(() => { expect(screen.getByTestId('wave-progress-bar-progress-indicator')).toHaveStyle({ left: '50%', }); }); + + constructorSpy.mockRestore(); }); }); diff --git a/src/components/Attachment/__tests__/__snapshots__/Card.test.js.snap b/src/components/Attachment/__tests__/__snapshots__/Card.test.js.snap index eedea8c26b..c77a49c329 100644 --- a/src/components/Attachment/__tests__/__snapshots__/Card.test.js.snap +++ b/src/components/Attachment/__tests__/__snapshots__/Card.test.js.snap @@ -25,13 +25,6 @@ exports[`Card (1) should render card without caption if attachment type is audio class="str-chat__message-attachment-card-audio-widget" data-testid="card-audio-widget" > -
@@ -242,13 +235,6 @@ exports[`Card (7) should render audio with caption using og_scrape_url and with class="str-chat__message-attachment-card-audio-widget" data-testid="card-audio-widget" > -
@@ -454,13 +440,6 @@ exports[`Card (10) should render audio without title if attachment type is audio class="str-chat__message-attachment-card-audio-widget" data-testid="card-audio-widget" > -
@@ -651,13 +630,6 @@ exports[`Card (13) should render audio without title and with caption using og_s class="str-chat__message-attachment-card-audio-widget" data-testid="card-audio-widget" > -
@@ -835,13 +807,6 @@ exports[`Card (16) should render audio widget with title & text in Card content class="str-chat__message-attachment-card-audio-widget" data-testid="card-audio-widget" > -
@@ -1368,13 +1333,6 @@ exports[`Card (25) should render audio widget with image loaded from thumb_url a class="str-chat__message-attachment-card-audio-widget" data-testid="card-audio-widget" > -
diff --git a/src/components/Attachment/hooks/useAudioController.ts b/src/components/Attachment/hooks/useAudioController.ts index 846370a428..ed881b4568 100644 --- a/src/components/Attachment/hooks/useAudioController.ts +++ b/src/components/Attachment/hooks/useAudioController.ts @@ -23,6 +23,7 @@ type AudioControllerParams = { playbackRates?: number[]; }; +/** @deprecated use useAudioPlayer instead */ export const useAudioController = ({ durationSeconds, mimeType, diff --git a/src/components/AudioPlayer/AudioPlayer.ts b/src/components/AudioPlayer/AudioPlayer.ts new file mode 100644 index 0000000000..c8684ed059 --- /dev/null +++ b/src/components/AudioPlayer/AudioPlayer.ts @@ -0,0 +1,314 @@ +import { StateStore } from 'stream-chat'; +import throttle from 'lodash.throttle'; +import type { AudioPlayerPlugin } from './plugins/AudioPlayerPlugin'; + +export type AudioPlayerErrorCode = + | 'failed-to-start' + | 'not-playable' + | 'seek-not-supported' + | (string & {}); + +export type RegisterAudioPlayerErrorParams = { + error?: Error; + errCode?: AudioPlayerErrorCode; +}; + +export type AudioDescriptor = { + id: string; + src: string; + /** Audio duration in seconds. */ + durationSeconds?: number; + mimeType?: string; +}; + +export type AudioPlayerPlayAudioParams = { + currentPlaybackRate?: number; + playbackRates?: number[]; +}; + +export type AudioPlayerState = { + canPlayRecord: boolean; + /** Current playback speed. Initiated with the first item of the playbackRates array. */ + currentPlaybackRate: number; + elementRef: HTMLAudioElement; + isPlaying: boolean; + playbackError: Error | null; + /** An array of fractional numeric values of playback speed to override the defaults (1.0, 1.5, 2.0) */ + playbackRates: number[]; + progressPercent: number; + secondsElapsed: number; +}; + +export type AudioPlayerOptions = AudioDescriptor & { + /** An array of fractional numeric values of playback speed to override the defaults (1.0, 1.5, 2.0) */ + playbackRates?: number[]; + plugins?: AudioPlayerPlugin[]; +}; + +const DEFAULT_PLAYBACK_RATES = [1.0, 1.5, 2.0]; + +const isSeekable = (audioElement: HTMLAudioElement) => + !(audioElement.duration === Infinity || isNaN(audioElement.duration)); + +export const defaultRegisterAudioPlayerError = ({ + error, +}: RegisterAudioPlayerErrorParams = {}) => { + if (!error) return; + console.error('[AUDIO PLAYER]', error); +}; + +export const elementIsPlaying = (audioElement: HTMLAudioElement | null) => + audioElement && !(audioElement.paused || audioElement.ended); + +export type SeekFn = (params: { clientX: number; currentTarget: HTMLDivElement }) => void; + +export class AudioPlayer { + state: StateStore; + private _id: string; + /** The audio MIME type that is checked before the audio is played. If the type is not supported the controller registers error in playbackError. */ + private _mimeType?: string; + private _durationSeconds?: number; + private _plugins = new Map(); + private playTimeout: ReturnType | undefined = undefined; + + constructor({ + durationSeconds, + id, + mimeType, + playbackRates: customPlaybackRates, + plugins, + src, + }: AudioPlayerOptions) { + this._id = id; + this._mimeType = mimeType; + this._durationSeconds = durationSeconds; + this.setPlugins(() => plugins ?? []); + + const playbackRates = customPlaybackRates?.length + ? customPlaybackRates + : DEFAULT_PLAYBACK_RATES; + + const elementRef = new Audio(src); + const canPlayRecord = mimeType ? !!elementRef.canPlayType(mimeType) : true; + + this.state = new StateStore({ + canPlayRecord, + currentPlaybackRate: playbackRates[0], + elementRef, + isPlaying: false, + playbackError: null, + playbackRates, + progressPercent: 0, + secondsElapsed: 0, + }); + + this.plugins.forEach((p) => p.onInit?.({ player: this })); + } + + private get plugins(): AudioPlayerPlugin[] { + return Array.from(this._plugins.values()); + } + + get canPlayRecord() { + return this.state.getLatestValue().canPlayRecord; + } + + get elementRef() { + return this.state.getLatestValue().elementRef; + } + + get isPlaying(): boolean { + return this.state.getLatestValue().isPlaying; + } + + get currentPlaybackRate() { + return this.state.getLatestValue().currentPlaybackRate; + } + + get playbackRates() { + return this.state.getLatestValue().playbackRates; + } + + get id() { + return this._id; + } + + get src() { + return this.elementRef.src; + } + + get secondsElapsed() { + return this.state.getLatestValue().secondsElapsed; + } + + get progressPercent() { + return this.state.getLatestValue().progressPercent; + } + + get durationSeconds() { + return this._durationSeconds; + } + + get mimeType() { + return this._mimeType; + } + + private setDescriptor({ durationSeconds, mimeType, src }: AudioDescriptor) { + if (mimeType !== this._mimeType) { + this._mimeType = mimeType; + } + + if (durationSeconds !== this._durationSeconds) { + this._durationSeconds = durationSeconds; + } + if (this.elementRef.src !== src) { + this.elementRef.src = src; + this.elementRef.load(); + } + } + + private setRef = (elementRef: HTMLAudioElement) => { + if (elementIsPlaying(this.elementRef)) { + this.pause(); + } + this.state.partialNext({ elementRef }); + }; + + setSecondsElapsed = (secondsElapsed: number) => { + this.state.partialNext({ + progressPercent: + this.elementRef && secondsElapsed + ? (secondsElapsed / this.elementRef.duration) * 100 + : 0, + secondsElapsed, + }); + }; + + setPlugins(setter: (currentPlugins: AudioPlayerPlugin[]) => AudioPlayerPlugin[]) { + this._plugins = setter(this.plugins).reduce((acc, plugin) => { + if (plugin.id) { + acc.set(plugin.id, plugin); + } + return acc; + }, new Map()); + } + + private setupSafetyTimeout = () => { + clearTimeout(this.playTimeout); + this.playTimeout = setTimeout(() => { + if (!this.elementRef) return; + try { + this.elementRef.pause(); + this.state.partialNext({ isPlaying: false }); + } catch (e) { + this.registerError({ errCode: 'failed-to-start' }); + } + }, 2000); + }; + + private clearSafetyTimeout = () => { + if (!this.elementRef) return; + clearTimeout(this.playTimeout); + this.playTimeout = undefined; + }; + + canPlayMimeType = (mimeType: string) => + !!(mimeType && this.elementRef?.canPlayType(mimeType)); + + play = async (params?: AudioPlayerPlayAudioParams) => { + if (elementIsPlaying(this.elementRef)) { + if (this.isPlaying) return; + this.state.partialNext({ isPlaying: true }); + return; + } + + const { currentPlaybackRate, playbackRates } = { + currentPlaybackRate: this.currentPlaybackRate, + playbackRates: this.playbackRates, + ...params, + }; + + if (!this.canPlayRecord) { + this.registerError({ errCode: 'not-playable' }); + return; + } + + this.elementRef.playbackRate = currentPlaybackRate ?? this.currentPlaybackRate; + + this.setupSafetyTimeout(); + + try { + await this.elementRef.play(); + this.state.partialNext({ + currentPlaybackRate, + isPlaying: true, + playbackRates, + }); + } catch (e) { + this.registerError({ error: e as Error }); + this.state.partialNext({ isPlaying: false }); + } finally { + this.clearSafetyTimeout(); + } + }; + + pause = () => { + if (!elementIsPlaying(this.elementRef)) return; + this.clearSafetyTimeout(); + + // existence of the element already checked by elementIsPlaying + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.elementRef!.pause(); + this.state.partialNext({ isPlaying: false }); + }; + + stop = () => { + this.pause(); + this.setSecondsElapsed(0); + this.elementRef.currentTime = 0; + }; + + togglePlay = async () => (this.isPlaying ? this.pause() : await this.play()); + + increasePlaybackRate = () => { + if (!this.elementRef) return; + let currentPlaybackRateIndex = this.state + .getLatestValue() + .playbackRates.findIndex((rate) => rate === this.currentPlaybackRate); + if (currentPlaybackRateIndex === -1) { + currentPlaybackRateIndex = 0; + } + const nextIndex = + currentPlaybackRateIndex === this.playbackRates.length - 1 + ? 0 + : currentPlaybackRateIndex + 1; + const currentPlaybackRate = this.playbackRates[nextIndex]; + this.state.partialNext({ currentPlaybackRate }); + this.elementRef.playbackRate = currentPlaybackRate; + }; + + seek = throttle(({ clientX, currentTarget }) => { + if (!(currentTarget && this.elementRef)) return; + if (!isSeekable(this.elementRef)) { + this.registerError({ errCode: 'seek-not-supported' }); + return; + } + + const { width, x } = currentTarget.getBoundingClientRect(); + + const ratio = (clientX - x) / width; + if (ratio > 1 || ratio < 0) return; + const currentTime = ratio * this.elementRef.duration; + this.setSecondsElapsed(currentTime); + this.elementRef.currentTime = currentTime; + }, 16); + + registerError = (params: RegisterAudioPlayerErrorParams) => { + defaultRegisterAudioPlayerError(params); + this.plugins.forEach(({ onError }) => onError?.({ player: this, ...params })); + }; + + onRemove = () => { + this.plugins.forEach(({ onRemove }) => onRemove?.({ player: this })); + }; +} diff --git a/src/components/AudioPlayer/AudioPlayerPool.ts b/src/components/AudioPlayer/AudioPlayerPool.ts new file mode 100644 index 0000000000..b5d1083d2c --- /dev/null +++ b/src/components/AudioPlayer/AudioPlayerPool.ts @@ -0,0 +1,123 @@ +import { + AudioPlayer, + type AudioPlayerOptions, + type AudioPlayerState, + defaultRegisterAudioPlayerError, +} from './AudioPlayer'; + +export class AudioPlayerPool { + pool = new Map void }>(); + + getOrAdd = (params: AudioPlayerOptions) => { + let player = this.pool.get(params.id)?.player; + if (player) return player; + player = new AudioPlayer(params); + + this.pool.set(params.id, { + player, + unsubscribe: this.registerSubscriptions(player), + }); + return player; + }; + + remove = (id: string) => { + const player = this.pool.get(id); + if (!player) return; + player.unsubscribe?.(); + player.player.stop(); + player.player.elementRef.src = ''; + player.player.elementRef.load(); + player.player.onRemove(); + this.pool.delete(id); + }; + + clear = () => { + Array.from(this.pool.values()).forEach(({ player }) => { + this.remove(player.id); + }); + }; + + registerSubscriptions = (player?: AudioPlayer) => { + if (!player) { + Array.from(this.pool.values()).forEach((p) => { + this.registerSubscriptions(p.player); + }); + return; + } + + const poolPlayer = this.pool.get(player.id); + + poolPlayer?.unsubscribe?.(); + + const audioElement = player.elementRef; + + const handleEnded = () => { + player.state.partialNext({ + isPlaying: false, + secondsElapsed: audioElement?.duration ?? player.durationSeconds ?? 0, + }); + }; + + const handleError = (e: HTMLMediaElementEventMap['error']) => { + // if fired probably is one of these (e.srcElement.error.code) + // 1 = MEDIA_ERR_ABORTED (fetch aborted by user/JS) + // 2 = MEDIA_ERR_NETWORK (network failed while fetching) + // 3 = MEDIA_ERR_DECODE (data fetched but couldn’t decode) + // 4 = MEDIA_ERR_SRC_NOT_SUPPORTED (no resource supported / bad type) + // reported during the mount so only logging to the console + const audio = e.currentTarget as HTMLAudioElement | null; + const state: Partial = { isPlaying: false }; + + if (!audio?.error?.code) { + player.state.partialNext(state); + return; + } + + if (audio.error.code === 4) { + state.canPlayRecord = false; + player.state.partialNext(state); + } + + const errorMsg = [ + undefined, + 'MEDIA_ERR_ABORTED: fetch aborted by user', + 'MEDIA_ERR_NETWORK: network failed while fetching', + 'MEDIA_ERR_DECODE: audio fetched but couldn’t decode', + 'MEDIA_ERR_SRC_NOT_SUPPORTED: source not supported', + ][audio?.error?.code]; + if (!errorMsg) return; + + defaultRegisterAudioPlayerError({ error: new Error(errorMsg + ` (${audio.src})`) }); + }; + + const handleTimeupdate = () => { + player.setSecondsElapsed(audioElement?.currentTime); + }; + + audioElement.addEventListener('ended', handleEnded); + audioElement.addEventListener('error', handleError); + audioElement.addEventListener('timeupdate', handleTimeupdate); + + return () => { + audioElement.pause(); + audioElement.removeEventListener('ended', handleEnded); + audioElement.removeEventListener('error', handleError); + audioElement.removeEventListener('timeupdate', handleTimeupdate); + }; + }; + + unregisterSubscriptions = (id?: string) => { + if (!id) { + Array.from(this.pool.values()).forEach(({ unsubscribe }) => unsubscribe?.()); + for (const { player } of this.pool.values()) { + this.pool.set(player.id, { player }); + } + return; + } + const player = this.pool.get(id); + if (!player) return; + + player.unsubscribe?.(); + delete player.unsubscribe; + }; +} diff --git a/src/components/AudioPlayer/WithAudioPlayback.tsx b/src/components/AudioPlayer/WithAudioPlayback.tsx new file mode 100644 index 0000000000..410df2d3ea --- /dev/null +++ b/src/components/AudioPlayer/WithAudioPlayback.tsx @@ -0,0 +1,82 @@ +import type { ReactNode } from 'react'; +import { useEffect, useMemo } from 'react'; +import type { AudioPlayerOptions } from './AudioPlayer'; +import { AudioPlayerPool } from './AudioPlayerPool'; +import { audioPlayerNotificationsPluginFactory } from './plugins/AudioPlayerNotificationsPlugin'; +import { useChatContext, useTranslationContext } from '../../context'; + +const audioPlayers = new AudioPlayerPool(); + +export type WithAudioPlaybackProps = { children?: ReactNode }; + +export const WithAudioPlayback = ({ children }: WithAudioPlaybackProps) => { + useEffect(() => { + audioPlayers.registerSubscriptions(); + return () => { + audioPlayers.clear(); + }; + }, []); + + return children; +}; + +export type UseAudioPlayerProps = { + /** + * 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 }: { src: string; requester?: string }) => + `${requester ?? 'requester-unknown'}:${src}`; + +export const useAudioPlayer = ({ + durationSeconds, + mimeType, + playbackRates, + plugins, + requester = '', + src, +}: UseAudioPlayerProps) => { + const { client } = useChatContext(); + const { t } = useTranslationContext(); + + const audioPlayer = useMemo( + () => + src + ? audioPlayers.getOrAdd({ + durationSeconds, + id: makeAudioPlayerId({ requester, src }), + mimeType, + playbackRates, + plugins, + src, + }) + : undefined, + [durationSeconds, mimeType, playbackRates, plugins, requester, src], + ); + + useEffect(() => { + if (!audioPlayer) return; + /** + * Avoid having to pass client and translation function to AudioPlayer instances + * and instead provide registerError function that overrides the default. + */ + const notificationsPlugin = audioPlayerNotificationsPluginFactory({ client, t }); + audioPlayer.setPlugins((currentPlugins) => [ + ...currentPlugins.filter((plugin) => plugin.id !== notificationsPlugin.id), + notificationsPlugin, + ]); + }, [audioPlayer, client, t]); + + return audioPlayer; +}; diff --git a/src/components/AudioPlayer/__tests__/AudioPlayer.test.js b/src/components/AudioPlayer/__tests__/AudioPlayer.test.js new file mode 100644 index 0000000000..a4b168f868 --- /dev/null +++ b/src/components/AudioPlayer/__tests__/AudioPlayer.test.js @@ -0,0 +1,323 @@ +import { AudioPlayer, elementIsPlaying } from '../AudioPlayer'; + +// ---- Keep throttle synchronous so seek assertions are deterministic ---- +jest.mock('lodash.throttle', () => (fn) => fn); + +// ---- Stable console noise filter (optional) ---- +const originalConsoleError = console.error; +beforeAll(() => { + jest.spyOn(console, 'error').mockImplementation((...args) => { + const msg = String(args[0]?.message ?? args[0] ?? ''); + if (/Not implemented/i.it(msg)) return; + originalConsoleError(...args); + }); +}); + +// ---- Helpers ---- +const SRC = 'https://example.com/a.mp3'; +const MIME = 'audio/mpeg'; + +const createdAudios = []; +const makeErrorPlugin = () => { + const onError = jest.fn(); + return { + onError, + plugin: { id: 'TestErrorPlugin', onError }, + }; +}; + +const makePlayer = (overrides = {}) => + new AudioPlayer({ + durationSeconds: 100, + id: 'id-1', + mimeType: MIME, + src: SRC, + ...overrides, + }); + +// ---- Tests ---- +describe('AudioPlayer', () => { + beforeEach(() => { + const RealAudio = window.Audio; + jest.spyOn(window, 'Audio').mockImplementation(function AudioMock(...args) { + const el = new RealAudio(...args); + createdAudios.push(el); + return el; + }); + + // Stub core media methods + jest.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => ({})); + jest + .spyOn(HTMLMediaElement.prototype, 'play') + .mockImplementation(() => Promise.resolve()); + jest.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => ({})); + // Default media flags + jest.spyOn(HTMLMediaElement.prototype, 'paused', 'get').mockReturnValue(true); + jest.spyOn(HTMLMediaElement.prototype, 'ended', 'get').mockReturnValue(false); + jest.spyOn(HTMLMediaElement.prototype, 'duration', 'get').mockReturnValue(100); + }); + + afterEach(() => { + jest.restoreAllMocks(); + createdAudios.length = 0; + }); + + it('constructor sets initial state (canPlayRecord & playbackRates)', () => { + jest.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue('maybe'); + + const player = makePlayer({ playbackRates: [1, 1.25, 1.5] }); + + // State comes from the real StateStore + expect(player.isPlaying).toBe(false); + expect(player.canPlayRecord).toBe(true); + expect(player.currentPlaybackRate).toBe(1); + expect(player.playbackRates).toEqual([1, 1.25, 1.5]); + expect(player.src).toBe(SRC); + expect(player.mimeType).toBe(MIME); + expect(player.durationSeconds).toBe(100); + }); + + it('constructor marks not playable when mimeType unsupported', () => { + jest.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue(''); + expect(makePlayer({ mimeType: 'audio/unknown' }).canPlayRecord).toBe(false); + }); + + it('canPlayMimeType delegates to elementRef.canPlayType', () => { + const player = makePlayer(); + const spy = jest.spyOn(player.elementRef, 'canPlayType').mockReturnValue('probably'); + expect(player.canPlayMimeType('audio/ogg')).toBe(true); + expect(spy).toHaveBeenCalledWith('audio/ogg'); + }); + + it('play() success updates isPlaying and playbackRate', async () => { + jest.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue('maybe'); + const player = makePlayer({ playbackRates: [1, 1.5, 2] }); + + await player.play({ currentPlaybackRate: 1.5 }); + + expect(player.isPlaying).toBe(true); + expect(player.currentPlaybackRate).toBe(1.5); + expect(player.elementRef.playbackRate).toBe(1.5); + }); + + it('play() early-return path when element is already playing', async () => { + const player = makePlayer(); + + // Make element look like it's already playing + jest.spyOn(HTMLMediaElement.prototype, 'paused', 'get').mockReturnValue(false); + + const playSpy = jest.spyOn(player.elementRef, 'play'); + + await player.play(); + expect(player.isPlaying).toBe(true); + expect(playSpy).not.toHaveBeenCalled(); + }); + + it('play() when not playable triggers registerError {errCode:not-playable}', async () => { + jest.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue(''); + const { onError, plugin } = makeErrorPlugin(); + const player = makePlayer({ mimeType: 'audio/zzz', plugins: [plugin] }); + await player.play(); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ errCode: 'not-playable', player }), + ); + expect(player.isPlaying).toBe(false); + }); + + it('play() when element.play rejects triggers registerError(error) and isPlaying=false', async () => { + const { onError, plugin } = makeErrorPlugin(); + const player = makePlayer({ plugins: [plugin] }); + jest.spyOn(player.elementRef, 'play').mockRejectedValueOnce(new Error('x')); + await player.play(); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ errCode: 'not-playable', player }), + ); + expect(player.isPlaying).toBe(false); + }); + + it('safety timeout pauses if play did not resolve within 2000ms', async () => { + jest.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue('maybe'); + jest.useFakeTimers({ now: Date.now() }); + const { onError, plugin } = makeErrorPlugin(); + const player = makePlayer({ plugins: [plugin] }); + + let resolve; + jest.spyOn(player.elementRef, 'play').mockImplementation( + () => + new Promise((res) => { + resolve = res; + }), + ); + const pauseSpy = jest.spyOn(player.elementRef, 'pause').mockImplementation(); + + const playPromise = player.play(); + jest.advanceTimersByTime(2000); + resolve(); + expect(pauseSpy).toHaveBeenCalledTimes(1); + expect(player.isPlaying).toBe(false); + expect(onError).not.toHaveBeenCalled(); + + jest.useRealTimers(); + await Promise.resolve(playPromise); + }); + + it('safety timeout registers failed-to-start if pause throws', () => { + jest.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue('maybe'); + jest.useFakeTimers({ now: Date.now() }); + const { onError, plugin } = makeErrorPlugin(); + const player = makePlayer({ plugins: [plugin] }); + + let resolve; + jest.spyOn(player.elementRef, 'play').mockImplementation( + () => + new Promise((res) => { + resolve = res; + }), + ); + jest.spyOn(player.elementRef, 'pause').mockImplementation(() => { + throw new Error('nope'); + }); + + player.play(); + jest.advanceTimersByTime(2000); + resolve(); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ errCode: 'failed-to-start', player }), + ); + + jest.useRealTimers(); + }); + + it('pause() when element is playing updates state and calls audioElement.pause()', () => { + const player = makePlayer(); + jest.spyOn(HTMLMediaElement.prototype, 'paused', 'get').mockReturnValue(false); + + const pauseSpy = jest.spyOn(player.elementRef, 'pause'); + player.pause(); + expect(pauseSpy).toHaveBeenCalled(); + expect(player.isPlaying).toBe(false); + }); + + it('pause() when element is not playing does nothing', () => { + const player = makePlayer(); + const pauseSpy = jest.spyOn(player.elementRef, 'pause'); + player.pause(); + expect(pauseSpy).not.toHaveBeenCalled(); + }); + + it('stop() pauses, resets secondsElapsed and currentTime', () => { + const player = makePlayer(); + const pauseSpy = jest.spyOn(player, 'pause'); + player.state.partialNext({ secondsElapsed: 50 }); + expect(player.secondsElapsed).toBe(50); + + player.stop(); + expect(pauseSpy).toHaveBeenCalled(); + expect(player.secondsElapsed).toBe(0); + expect(player.elementRef.currentTime).toBe(0); + }); + + it('togglePlay delegates to play() / pause()', async () => { + const p = makePlayer(); + + const playSpy = jest.spyOn(p, 'play'); + const pauseSpy = jest.spyOn(p, 'pause'); + + await p.togglePlay(); + expect(playSpy).toHaveBeenCalled(); + + jest.spyOn(HTMLMediaElement.prototype, 'paused', 'get').mockReturnValue(false); + p.state.partialNext({ isPlaying: true }); + await p.togglePlay(); + expect(pauseSpy).toHaveBeenCalled(); + p.state.partialNext({ isPlaying: false }); + }); + + it('increasePlaybackRate cycles through playbackRates', () => { + const p = makePlayer({ playbackRates: [1, 1.25, 1.5] }); + expect(p.currentPlaybackRate).toBe(1); + expect(p.elementRef.playbackRate).toBe(1); + + p.increasePlaybackRate(); + expect(p.currentPlaybackRate).toBe(1.25); + expect(p.elementRef.playbackRate).toBe(1.25); + + p.increasePlaybackRate(); + expect(p.currentPlaybackRate).toBe(1.5); + expect(p.elementRef.playbackRate).toBe(1.5); + + p.increasePlaybackRate(); + expect(p.currentPlaybackRate).toBe(1); + expect(p.elementRef.playbackRate).toBe(1); + }); + + it('seek updates currentTime and progress when seekable', () => { + const p = makePlayer(); + jest.spyOn(p.elementRef, 'duration', 'get').mockReturnValue(120); + + const target = document.createElement('div'); + jest.spyOn(target, 'getBoundingClientRect').mockReturnValue({ width: 100, x: 0 }); + + p.seek({ clientX: 50, currentTarget: target }); + + expect(p.elementRef.currentTime).toBeCloseTo(60, 5); + expect(p.state.getLatestValue().progressPercent).toBeCloseTo(50, 5); + expect(p.state.getLatestValue().secondsElapsed).toBeCloseTo(60, 5); + }); + + it('seek does nothing if ratio is out of 0..1', () => { + const p = makePlayer(); + jest.spyOn(p.elementRef, 'duration', 'get').mockReturnValue(120); + const target = document.createElement('div'); + jest.spyOn(target, 'getBoundingClientRect').mockReturnValue({ width: 100, x: 0 }); + + p.seek({ clientX: 150, currentTarget: target }); // clientX > width + expect(p.state.getLatestValue().secondsElapsed).toBe(0); + }); + + it('seek emits errCode seek-not-supported when not seekable', () => { + const { onError, plugin } = makeErrorPlugin(); + const player = makePlayer({ plugins: [plugin] }); + + // not seekable + jest.spyOn(player.elementRef, 'duration', 'get').mockReturnValue(NaN); + + const target = document.createElement('div'); + jest.spyOn(target, 'getBoundingClientRect').mockReturnValue({ width: 100, x: 0 }); + + player.seek({ clientX: 50, currentTarget: target }); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ errCode: 'seek-not-supported', player }), + ); + }); + + it('setSecondsElapsed updates seconds and progressPercent in state', () => { + const p = makePlayer(); + jest.spyOn(p.elementRef, 'duration', 'get').mockReturnValue(200); + + p.setSecondsElapsed(40); + const st = p.state.getLatestValue(); + expect(st.secondsElapsed).toBe(40); + expect(st.progressPercent).toBeCloseTo(20, 5); // 40/200*100 + }); + + it('elementIsPlaying utility', () => { + const el = document.createElement('audio'); + + const pausedSpy = jest + .spyOn(HTMLMediaElement.prototype, 'paused', 'get') + .mockReturnValue(true); + const endedSpy = jest + .spyOn(HTMLMediaElement.prototype, 'ended', 'get') + .mockReturnValue(false); + + expect(elementIsPlaying(el)).toBe(false); + + pausedSpy.mockReturnValue(false); + expect(elementIsPlaying(el)).toBe(true); + + endedSpy.mockReturnValue(true); + expect(elementIsPlaying(el)).toBe(false); + }); +}); diff --git a/src/components/AudioPlayer/__tests__/WithAudioPlayback.test.js b/src/components/AudioPlayer/__tests__/WithAudioPlayback.test.js new file mode 100644 index 0000000000..b6e9b0656b --- /dev/null +++ b/src/components/AudioPlayer/__tests__/WithAudioPlayback.test.js @@ -0,0 +1,321 @@ +// WithAudioPlayback.test.js +import React, { useEffect } from 'react'; +import '@testing-library/jest-dom'; +import { act, cleanup, render } from '@testing-library/react'; + +import { useAudioPlayer, WithAudioPlayback } from '../WithAudioPlayback'; +import * as audioModule from '../AudioPlayer'; // to spy on defaultRegisterAudioPlayerError + +// mock context used by WithAudioPlayback +jest.mock('../../../context', () => { + const mockAddError = jest.fn(); + const mockClient = { notifications: { addError: mockAddError } }; + const t = (s) => s; + return { + __esModule: true, + mockAddError, + useChatContext: () => ({ client: mockClient }), + useTranslationContext: () => ({ t }), + // export spy so tests can assert on it + }; +}); + +// make throttle a no-op (so seek/time-related stuff runs synchronously) +jest.mock('lodash.throttle', () => (fn) => fn); + +// ------------------ imports FROM mocks ------------------ + +import { mockAddError as addErrorSpy } from '../../../context'; + +const defaultRegisterSpy = jest.spyOn(audioModule, 'defaultRegisterAudioPlayerError'); + +// silence console.error in tests +jest.spyOn(console, 'error').mockImplementation(() => {}); + +// ------------------ window.Audio + media stubs ------------------ + +const createdAudios = []; + +beforeEach(() => { + // Return a real
); }; +export type VoiceRecordingPlayerProps = Pick & { + /** 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 ? : null; +}; + export type QuotedVoiceRecordingProps = Pick; export const QuotedVoiceRecording = ({ attachment }: QuotedVoiceRecordingProps) => { diff --git a/src/components/Attachment/__tests__/Audio.test.js b/src/components/Attachment/__tests__/Audio.test.js index ddfba44565..20becd1474 100644 --- a/src/components/Attachment/__tests__/Audio.test.js +++ b/src/components/Attachment/__tests__/Audio.test.js @@ -5,7 +5,7 @@ import '@testing-library/jest-dom'; import { Audio } from '../Audio'; import { generateAudioAttachment, generateMessage } from '../../../mock-builders'; import { prettifyFileSize } from '../../MessageInput/hooks/utils'; -import { WithAudioPlayback } from '../../AudioPlayer/WithAudioPlayback'; +import { WithAudioPlayback } from '../../AudioPlayback'; import { MessageProvider } from '../../../context'; jest.mock('../../../context/ChatContext', () => ({ diff --git a/src/components/Attachment/__tests__/Card.test.js b/src/components/Attachment/__tests__/Card.test.js index 9b2ab5432e..28333a607e 100644 --- a/src/components/Attachment/__tests__/Card.test.js +++ b/src/components/Attachment/__tests__/Card.test.js @@ -24,7 +24,7 @@ import { mockTranslationContext, useMockedApis, } from '../../../mock-builders'; -import { WithAudioPlayback } from '../../AudioPlayer'; +import { WithAudioPlayback } from '../../AudioPlayback'; let chatClient; let channel; diff --git a/src/components/Attachment/__tests__/VoiceRecording.test.js b/src/components/Attachment/__tests__/VoiceRecording.test.js index 96152df043..180f50a96c 100644 --- a/src/components/Attachment/__tests__/VoiceRecording.test.js +++ b/src/components/Attachment/__tests__/VoiceRecording.test.js @@ -9,7 +9,7 @@ import { import { VoiceRecording, VoiceRecordingPlayer } from '../VoiceRecording'; import { ChatProvider, MessageProvider } from '../../../context'; import { ResizeObserverMock } from '../../../mock-builders/browser'; -import { WithAudioPlayback } from '../../AudioPlayer'; +import { WithAudioPlayback } from '../../AudioPlayback'; const AUDIO_RECORDING_PLAYER_TEST_ID = 'voice-recording-widget'; const QUOTED_AUDIO_RECORDING_TEST_ID = 'quoted-voice-recording-widget'; diff --git a/src/components/Attachment/index.ts b/src/components/Attachment/index.ts index 377cbe2468..70509c8a3a 100644 --- a/src/components/Attachment/index.ts +++ b/src/components/Attachment/index.ts @@ -9,5 +9,6 @@ export * from './FileAttachment'; export * from './Geolocation'; export * from './UnsupportedAttachment'; export * from './utils'; +export * from './VoiceRecording'; export { useAudioController } from './hooks/useAudioController'; export * from '../Location/hooks/useLiveLocationSharingManager'; diff --git a/src/components/AudioPlayer/AudioPlayer.ts b/src/components/AudioPlayback/AudioPlayer.ts similarity index 94% rename from src/components/AudioPlayer/AudioPlayer.ts rename to src/components/AudioPlayback/AudioPlayer.ts index 8956abd24b..03407496e7 100644 --- a/src/components/AudioPlayer/AudioPlayer.ts +++ b/src/components/AudioPlayback/AudioPlayer.ts @@ -1,6 +1,6 @@ import { StateStore } from 'stream-chat'; import throttle from 'lodash.throttle'; -import type { AudioPlayerPlugin } from './plugins/AudioPlayerPlugin'; +import type { AudioPlayerPlugin } from './plugins'; import type { AudioPlayerPool } from './AudioPlayerPool'; export type AudioPlayerErrorCode = @@ -19,7 +19,10 @@ export type AudioDescriptor = { src: string; /** Audio duration in seconds. */ durationSeconds?: number; + fileSize?: number | string; mimeType?: string; + title?: string; + waveformData?: number[]; }; export type AudioPlayerPlayAudioParams = { @@ -66,11 +69,8 @@ export type SeekFn = (params: { clientX: number; currentTarget: HTMLDivElement } export class AudioPlayer { state: StateStore; - private _id: string; /** The audio MIME type that is checked before the audio is played. If the type is not supported the controller registers error in playbackError. */ - private _mimeType?: string; - private _durationSeconds?: number; - private _src: string; + private _data: AudioDescriptor; private _plugins = new Map(); private playTimeout: ReturnType | undefined = undefined; private unsubscribeEventListeners: (() => void) | null = null; @@ -83,17 +83,25 @@ export class AudioPlayer { constructor({ durationSeconds, + fileSize, id, mimeType, playbackRates: customPlaybackRates, plugins, pool, src, + title, + waveformData, }: AudioPlayerOptions) { - this._id = id; - this._mimeType = mimeType; - this._durationSeconds = durationSeconds; - this._src = src; + this._data = { + durationSeconds, + fileSize, + id, + mimeType, + src, + title, + waveformData, + }; this._pool = pool; this.setPlugins(() => plugins ?? []); @@ -142,28 +150,40 @@ export class AudioPlayer { return this.state.getLatestValue().playbackRates; } + get durationSeconds() { + return this._data.durationSeconds; + } + + get fileSize() { + return this._data.fileSize; + } + get id() { - return this._id; + return this._data.id; } get src() { - return this._src; + return this._data.src; } - get secondsElapsed() { - return this.state.getLatestValue().secondsElapsed; + get mimeType() { + return this._data.mimeType; } - get progressPercent() { - return this.state.getLatestValue().progressPercent; + get title() { + return this._data.title; } - get durationSeconds() { - return this._durationSeconds; + get waveformData() { + return this._data.waveformData; } - get mimeType() { - return this._mimeType; + get secondsElapsed() { + return this.state.getLatestValue().secondsElapsed; + } + + get progressPercent() { + return this.state.getLatestValue().progressPercent; } get disposed() { @@ -176,8 +196,8 @@ export class AudioPlayer { } if (!this.elementRef) { const el = this._pool.acquireElement({ - ownerId: this._id, - src: this._src, + ownerId: this.id, + src: this.src, }); this.setRef(el); } @@ -253,15 +273,15 @@ export class AudioPlayer { }; private setDescriptor({ durationSeconds, mimeType, src }: AudioDescriptor) { - if (mimeType !== this._mimeType) { - this._mimeType = mimeType; + if (mimeType !== this.mimeType) { + this._data.mimeType = mimeType; } - if (durationSeconds !== this._durationSeconds) { - this._durationSeconds = durationSeconds; + if (durationSeconds !== this.durationSeconds) { + this._data.durationSeconds = durationSeconds; } - if (src !== this._src) { - this._src = src; + if (src !== this.src) { + this._data.src = src; if (this.elementRef) { this.elementRef.src = src; } @@ -285,7 +305,7 @@ export class AudioPlayer { } } if (this.elementRef) { - this._pool.releaseElement(this._id); + this._pool.releaseElement(this.id); this.setRef(null); } } @@ -380,6 +400,7 @@ export class AudioPlayer { isPlaying: true, playbackRates, }); + this._pool.setActiveAudioPlayer(this); } catch (e) { this.registerError({ error: e as Error }); this.state.partialNext({ isPlaying: false }); diff --git a/src/components/AudioPlayer/AudioPlayerPool.ts b/src/components/AudioPlayback/AudioPlayerPool.ts similarity index 65% rename from src/components/AudioPlayer/AudioPlayerPool.ts rename to src/components/AudioPlayback/AudioPlayerPool.ts index 84006312b1..d3d732c1ca 100644 --- a/src/components/AudioPlayer/AudioPlayerPool.ts +++ b/src/components/AudioPlayback/AudioPlayerPool.ts @@ -1,11 +1,19 @@ import { AudioPlayer, type AudioPlayerOptions } from './AudioPlayer'; +import { StateStore } from 'stream-chat'; + +export type AudioPlayerPoolState = { + activeAudioPlayer: AudioPlayer | null; +}; export class AudioPlayerPool { - pool = new Map(); + state: StateStore = new StateStore({ + activeAudioPlayer: null, + }); + private pool = new Map(); private audios = new Map(); private sharedAudio: HTMLAudioElement | null = null; private sharedOwnerId: string | null = null; - private allowConcurrentPlayback: boolean; + private readonly allowConcurrentPlayback: boolean; constructor(config?: { allowConcurrentPlayback?: boolean }) { this.allowConcurrentPlayback = !!config?.allowConcurrentPlayback; @@ -15,6 +23,10 @@ export class AudioPlayerPool { return Array.from(this.pool.values()); } + get activeAudioPlayer() { + return this.state.getLatestValue().activeAudioPlayer; + } + getOrAdd = (params: Omit) => { let player = this.pool.get(params.id); if (player) { @@ -29,6 +41,14 @@ export class AudioPlayerPool { return player; }; + /** + * In case of allowConcurrentPlayback enabled, a new Audio is created and assigned to the given audioPlayer owner. + * In case of disabled concurrency, the shared audio ownership is transferred to the new owner loading the owner's + * source. + * + * @param ownerId + * @param src + */ acquireElement = ({ ownerId, src }: { ownerId: string; src: string }) => { if (!this.allowConcurrentPlayback) { // Single shared element mode @@ -63,6 +83,14 @@ export class AudioPlayerPool { return audio; }; + /** + * Removes the given audio players ownership of the shared audio element (in case of concurrent playback is disabled) + * and pauses the reproduction of the audio. + * In case of concurrent playback mode (allowConcurrentPlayback enabled), the audio is paused, + * its source cleared and removed from the audios pool readied for garbage collection. + * + * @param ownerId + */ releaseElement = (ownerId: string) => { if (!this.allowConcurrentPlayback) { if (this.sharedOwnerId !== ownerId) return; @@ -93,18 +121,30 @@ export class AudioPlayerPool { this.audios.delete(ownerId); }; + /** Sets active audio player when allowConcurrentPlayback is disabled */ + setActiveAudioPlayer = (activeAudioPlayer: AudioPlayer | null) => { + if (this.allowConcurrentPlayback) return; + this.state.partialNext({ activeAudioPlayer }); + }; + + /** Removes the AudioPlayer instance from the pool of players */ deregister(id: string) { if (this.pool.has(id)) { this.pool.delete(id); } + if (this.activeAudioPlayer?.id === id) { + this.setActiveAudioPlayer(null); + } } + /** Performs all the necessary cleanup actions and removes the player from the pool */ remove = (id: string) => { const player = this.pool.get(id); if (!player) return; player.requestRemoval(); }; + /** Removes and cleans up all the players from the pool */ clear = () => { this.players.forEach((player) => { this.remove(player.id); diff --git a/src/components/AudioPlayer/WithAudioPlayback.tsx b/src/components/AudioPlayback/WithAudioPlayback.tsx similarity index 84% rename from src/components/AudioPlayer/WithAudioPlayback.tsx rename to src/components/AudioPlayback/WithAudioPlayback.tsx index 224e085680..bd6904de8d 100644 --- a/src/components/AudioPlayer/WithAudioPlayback.tsx +++ b/src/components/AudioPlayback/WithAudioPlayback.tsx @@ -1,9 +1,11 @@ import React, { useContext, useState } from 'react'; import { useEffect } from 'react'; import type { AudioPlayerOptions } from './AudioPlayer'; +import type { AudioPlayerPoolState } from './AudioPlayerPool'; import { AudioPlayerPool } from './AudioPlayerPool'; import { audioPlayerNotificationsPluginFactory } from './plugins/AudioPlayerNotificationsPlugin'; import { useChatContext, useTranslationContext } from '../../context'; +import { useStateStore } from '../../store'; export type WithAudioPlaybackProps = { children?: React.ReactNode; @@ -55,11 +57,14 @@ const makeAudioPlayerId = ({ requester, src }: { src: string; requester?: string export const useAudioPlayer = ({ durationSeconds, + fileSize, mimeType, playbackRates, plugins, requester = '', src, + title, + waveformData, }: UseAudioPlayerProps) => { const { client } = useChatContext(); const { t } = useTranslationContext(); @@ -69,11 +74,14 @@ export const useAudioPlayer = ({ src && audioPlayers ? audioPlayers.getOrAdd({ durationSeconds, + fileSize, id: makeAudioPlayerId({ requester, src }), mimeType, playbackRates, plugins, src, + title, + waveformData, }) : undefined; @@ -92,3 +100,14 @@ export const useAudioPlayer = ({ return audioPlayer; }; + +const activeAudioPlayerSelector = ({ activeAudioPlayer }: AudioPlayerPoolState) => ({ + activeAudioPlayer, +}); + +export const useActiveAudioPlayer = () => { + const { audioPlayers } = useContext(AudioPlayerContext); + const { activeAudioPlayer } = + useStateStore(audioPlayers?.state, activeAudioPlayerSelector) ?? {}; + return activeAudioPlayer; +}; diff --git a/src/components/AudioPlayer/__tests__/AudioPlayer.test.js b/src/components/AudioPlayback/__tests__/AudioPlayer.test.js similarity index 98% rename from src/components/AudioPlayer/__tests__/AudioPlayer.test.js rename to src/components/AudioPlayback/__tests__/AudioPlayer.test.js index af2f3da37d..0c180027c8 100644 --- a/src/components/AudioPlayer/__tests__/AudioPlayer.test.js +++ b/src/components/AudioPlayback/__tests__/AudioPlayer.test.js @@ -31,6 +31,7 @@ const makePlayer = (overrides = {}) => { acquireElement: ({ src }) => new Audio(src), deregister: () => {}, releaseElement: () => {}, + setActiveAudioPlayer: jest.fn(), }; return new AudioPlayer({ durationSeconds: 100, @@ -107,6 +108,8 @@ describe('AudioPlayer', () => { expect(player.isPlaying).toBe(true); expect(player.currentPlaybackRate).toBe(1.5); expect(player.elementRef.playbackRate).toBe(1.5); + // eslint-disable-next-line no-underscore-dangle + expect(player._pool.setActiveAudioPlayer).toHaveBeenCalledWith(player); }); it('play() early-return path when element is already playing', async () => { diff --git a/src/components/AudioPlayer/__tests__/AudioPlayerPool.test.js b/src/components/AudioPlayback/__tests__/AudioPlayerPool.test.js similarity index 77% rename from src/components/AudioPlayer/__tests__/AudioPlayerPool.test.js rename to src/components/AudioPlayback/__tests__/AudioPlayerPool.test.js index 30d28a44a7..0f55688ce0 100644 --- a/src/components/AudioPlayer/__tests__/AudioPlayerPool.test.js +++ b/src/components/AudioPlayback/__tests__/AudioPlayerPool.test.js @@ -144,4 +144,44 @@ describe('AudioPlayerPool', () => { expect(spy1).not.toHaveBeenCalled(); expect(spy2).toHaveBeenCalled(); }); + + it('single-playback mode: removes a player', () => { + const pool = new AudioPlayerPool({ allowConcurrentPlayback: false }); + const player = makePlayer(pool, { id: 'o1', src: 'https://example.com/a.mp3' }); + pool.acquireElement({ ownerId: player.id, src: player.src }); + expect(pool.players).toHaveLength(1); + expect(Object.keys(pool.audios)).toHaveLength(0); + pool.remove(player.id); + expect(pool.players).toHaveLength(0); + }); + + it('concurrent-playback mode: removes a player', () => { + const pool = new AudioPlayerPool({ allowConcurrentPlayback: true }); + const player = makePlayer(pool, { id: 'o1', src: 'https://example.com/a.mp3' }); + const element = pool.acquireElement({ ownerId: player.id, src: player.src }); + expect(pool.players).toHaveLength(1); + expect(pool.audios.get(player.id)).toBe(element); + pool.remove(player.id); + expect(pool.players).toHaveLength(0); + expect(Object.keys(pool.audios)).toHaveLength(0); + }); + + it('sets active player only in single-playback mode', () => { + const poolConcurrent = new AudioPlayerPool({ allowConcurrentPlayback: true }); + const player1 = makePlayer(poolConcurrent, { + id: 'o1', + src: 'https://example.com/a.mp3', + }); + const poolSingle = new AudioPlayerPool({ allowConcurrentPlayback: false }); + const player2 = makePlayer(poolSingle, { + id: 'o1', + src: 'https://example.com/b.mp3', + }); + poolConcurrent.setActiveAudioPlayer(player1); + expect(poolConcurrent.players).toHaveLength(1); + expect(poolConcurrent.activeAudioPlayer).toBeNull(); + poolSingle.setActiveAudioPlayer(player2); + expect(poolSingle.players).toHaveLength(1); + expect(poolSingle.activeAudioPlayer).toBe(player2); + }); }); diff --git a/src/components/AudioPlayer/__tests__/WithAudioPlayback.test.js b/src/components/AudioPlayback/__tests__/WithAudioPlayback.test.js similarity index 100% rename from src/components/AudioPlayer/__tests__/WithAudioPlayback.test.js rename to src/components/AudioPlayback/__tests__/WithAudioPlayback.test.js diff --git a/src/components/AudioPlayer/index.ts b/src/components/AudioPlayback/index.ts similarity index 67% rename from src/components/AudioPlayer/index.ts rename to src/components/AudioPlayback/index.ts index 94a3476976..8ee5c7bfad 100644 --- a/src/components/AudioPlayer/index.ts +++ b/src/components/AudioPlayback/index.ts @@ -1,5 +1,7 @@ +export { type AudioPlayerState } from './AudioPlayer'; export * from './plugins'; export { + useActiveAudioPlayer, useAudioPlayer, type UseAudioPlayerProps, type WithAudioPlaybackProps, diff --git a/src/components/AudioPlayer/plugins/AudioPlayerNotificationsPlugin.ts b/src/components/AudioPlayback/plugins/AudioPlayerNotificationsPlugin.ts similarity index 100% rename from src/components/AudioPlayer/plugins/AudioPlayerNotificationsPlugin.ts rename to src/components/AudioPlayback/plugins/AudioPlayerNotificationsPlugin.ts diff --git a/src/components/AudioPlayer/plugins/AudioPlayerPlugin.ts b/src/components/AudioPlayback/plugins/AudioPlayerPlugin.ts similarity index 100% rename from src/components/AudioPlayer/plugins/AudioPlayerPlugin.ts rename to src/components/AudioPlayback/plugins/AudioPlayerPlugin.ts diff --git a/src/components/AudioPlayer/plugins/__tests__/AudioPlayerNotificationsPlugin.test.js b/src/components/AudioPlayback/plugins/__tests__/AudioPlayerNotificationsPlugin.test.js similarity index 100% rename from src/components/AudioPlayer/plugins/__tests__/AudioPlayerNotificationsPlugin.test.js rename to src/components/AudioPlayback/plugins/__tests__/AudioPlayerNotificationsPlugin.test.js diff --git a/src/components/AudioPlayer/plugins/index.ts b/src/components/AudioPlayback/plugins/index.ts similarity index 100% rename from src/components/AudioPlayer/plugins/index.ts rename to src/components/AudioPlayback/plugins/index.ts diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 25dc204139..32fc8fe0fb 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -91,7 +91,7 @@ import { getVideoAttachmentConfiguration, } from '../Attachment/attachment-sizing'; import { useSearchFocusedMessage } from '../../experimental/Search/hooks'; -import { WithAudioPlayback } from '../AudioPlayer/WithAudioPlayback'; +import { WithAudioPlayback } from '../AudioPlayback'; type ChannelPropsForwardedToComponentContext = Pick< ComponentContextValue, diff --git a/src/components/MediaRecorder/AudioRecorder/AudioRecordingPreview.tsx b/src/components/MediaRecorder/AudioRecorder/AudioRecordingPreview.tsx index d5f536ca12..52e2c225f3 100644 --- a/src/components/MediaRecorder/AudioRecorder/AudioRecordingPreview.tsx +++ b/src/components/MediaRecorder/AudioRecorder/AudioRecordingPreview.tsx @@ -2,8 +2,7 @@ import React, { useEffect } from 'react'; import { PauseIcon, PlayIcon } from '../../MessageInput/icons'; import { RecordingTimer } from './RecordingTimer'; import { WaveProgressBar } from '../../Attachment'; -import type { AudioPlayerState } from '../../AudioPlayer/AudioPlayer'; -import { useAudioPlayer } from '../../AudioPlayer/WithAudioPlayback'; +import { type AudioPlayerState, useAudioPlayer } from '../../AudioPlayback'; import { useStateStore } from '../../../store'; const audioPlayerStateSelector = (state: AudioPlayerState) => ({ @@ -29,6 +28,7 @@ export const AudioRecordingPreview = ({ durationSeconds, mimeType, src, + waveformData, }); const { isPlaying, progress, secondsElapsed } = diff --git a/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecorder.test.js b/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecorder.test.js index 76fd5f3977..31e2540b21 100644 --- a/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecorder.test.js +++ b/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecorder.test.js @@ -33,7 +33,7 @@ import { import { generateDataavailableEvent } from '../../../../mock-builders/browser/events/dataavailable'; import { AudioRecorder } from '../AudioRecorder'; import { MediaRecordingState } from '../../classes'; -import { WithAudioPlayback } from '../../../AudioPlayer'; +import { WithAudioPlayback } from '../../../AudioPlayback'; const PERM_DENIED_NOTIFICATION_TEXT = 'To start recording, allow the microphone access in your browser'; diff --git a/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecordingPreview.test.js b/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecordingPreview.test.js index b5f57c8371..48bfdbb637 100644 --- a/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecordingPreview.test.js +++ b/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecordingPreview.test.js @@ -2,10 +2,7 @@ import React, { useEffect } from 'react'; import '@testing-library/jest-dom'; import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; import { AudioRecordingPreview } from '../AudioRecordingPreview'; -import { - useAudioPlayer, - WithAudioPlayback, -} from '../../../AudioPlayer/WithAudioPlayback'; +import { useAudioPlayer, WithAudioPlayback } from '../../../AudioPlayback'; import { generateAudioAttachment } from '../../../../mock-builders'; const TOGGLE_PLAY_BTN_TEST_ID = 'audio-recording-preview-toggle-play-btn'; diff --git a/src/components/MessageInput/AttachmentPreviewList/VoiceRecordingPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/VoiceRecordingPreview.tsx index 8c16aac681..dc878cdc78 100644 --- a/src/components/MessageInput/AttachmentPreviewList/VoiceRecordingPreview.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/VoiceRecordingPreview.tsx @@ -6,8 +6,7 @@ import { FileIcon } from '../../ReactFileUtilities'; import type { LocalVoiceRecordingAttachment } from 'stream-chat'; import type { UploadAttachmentPreviewProps } from './types'; import { useTranslationContext } from '../../../context'; -import type { AudioPlayerState } from '../../AudioPlayer/AudioPlayer'; -import { useAudioPlayer } from '../../AudioPlayer/WithAudioPlayback'; +import { type AudioPlayerState, useAudioPlayer } from '../../AudioPlayback'; import { useStateStore } from '../../../store'; const audioPlayerStateSelector = (state: AudioPlayerState) => ({ diff --git a/src/components/index.ts b/src/components/index.ts index 475ee09761..9f9761d4eb 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,6 +1,6 @@ export * from './AIStateIndicator'; export * from './Attachment'; -export * from './AudioPlayer'; +export * from './AudioPlayback'; export * from './Avatar'; export * from './Channel'; export * from './ChannelHeader'; From 7c298f6743f2bedb6d62231ff71f906d99933fa1 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 13 Nov 2025 14:52:52 +0100 Subject: [PATCH 13/15] feat: keep the AudioPlayer descriptor data updated when its retrieval from the pool --- src/components/AudioPlayback/AudioPlayer.ts | 17 ++------ .../AudioPlayback/AudioPlayerPool.ts | 10 ++++- .../__tests__/AudioPlayerPool.test.js | 40 ++++++++++++++----- 3 files changed, 43 insertions(+), 24 deletions(-) diff --git a/src/components/AudioPlayback/AudioPlayer.ts b/src/components/AudioPlayback/AudioPlayer.ts index 03407496e7..93a0350e1b 100644 --- a/src/components/AudioPlayback/AudioPlayer.ts +++ b/src/components/AudioPlayback/AudioPlayer.ts @@ -272,19 +272,10 @@ export class AudioPlayer { } }; - private setDescriptor({ durationSeconds, mimeType, src }: AudioDescriptor) { - if (mimeType !== this.mimeType) { - this._data.mimeType = mimeType; - } - - if (durationSeconds !== this.durationSeconds) { - this._data.durationSeconds = durationSeconds; - } - if (src !== this.src) { - this._data.src = src; - if (this.elementRef) { - this.elementRef.src = src; - } + setDescriptor(descriptor: AudioDescriptor) { + this._data = { ...this._data, ...descriptor }; + if (descriptor.src !== this.src && this.elementRef) { + this.elementRef.src = descriptor.src; } } diff --git a/src/components/AudioPlayback/AudioPlayerPool.ts b/src/components/AudioPlayback/AudioPlayerPool.ts index d3d732c1ca..f5936daa48 100644 --- a/src/components/AudioPlayback/AudioPlayerPool.ts +++ b/src/components/AudioPlayback/AudioPlayerPool.ts @@ -28,13 +28,19 @@ export class AudioPlayerPool { } getOrAdd = (params: Omit) => { + const { playbackRates, plugins, ...descriptor } = params; let player = this.pool.get(params.id); if (player) { - if (!player.disposed) return player; + if (!player.disposed) { + player.setDescriptor(descriptor); + return player; + } this.deregister(params.id); } player = new AudioPlayer({ - ...params, + playbackRates, + plugins, + ...descriptor, pool: this, }); this.pool.set(params.id, player); diff --git a/src/components/AudioPlayback/__tests__/AudioPlayerPool.test.js b/src/components/AudioPlayback/__tests__/AudioPlayerPool.test.js index 0f55688ce0..bfad61d582 100644 --- a/src/components/AudioPlayback/__tests__/AudioPlayerPool.test.js +++ b/src/components/AudioPlayback/__tests__/AudioPlayerPool.test.js @@ -29,22 +29,44 @@ describe('AudioPlayerPool', () => { jest.restoreAllMocks(); createdAudios.length = 0; }); - - const makePlayer = (pool, { id, mimeType = 'audio/mpeg', src }) => + const defaultDescriptor = { durationSeconds: 100, mimeType: 'audio/mpeg' }; + const makePlayer = (pool, descriptor) => pool.getOrAdd({ - durationSeconds: 100, - id, - mimeType, - src, + ...defaultDescriptor, + ...descriptor, }); - it('getOrAdd returns same instance for same id and does not auto-register listeners', () => { + it('getOrAdd returns same instance for same id and does not auto-register listeners, updates descriptor fields', () => { const pool = new AudioPlayerPool(); - const p1 = makePlayer(pool, { id: 'a', src: 'https://example.com/a.mp3' }); + const p1 = makePlayer(pool, { + durationSeconds: 3, + fileSize: 35, + id: 'a', + mimeType: 'audio/abc', + src: 'https://example.com/a.mp3', + title: 'Title A', + waveformData: [1], + }); const regSpy = jest.spyOn(p1, 'registerSubscriptions'); - const p1Again = makePlayer(pool, { id: 'a', src: 'https://example.com/a.mp3' }); + const p1Again = makePlayer(pool, { + durationSeconds: 10, + id: 'a', + mimeType: 'audio/mpeg', + src: 'https://example.com/b.mp3', + waveformData: [2], + }); expect(p1Again).toBe(p1); expect(regSpy).not.toHaveBeenCalled(); + // eslint-disable-next-line no-underscore-dangle + expect(p1._data).toStrictEqual({ + durationSeconds: 10, + fileSize: 35, + id: 'a', + mimeType: 'audio/mpeg', + src: 'https://example.com/b.mp3', + title: 'Title A', + waveformData: [2], + }); }); it('concurrent mode: per-owner elements are created lazily; src set without explicit load()', () => { From 25743298a5cffe7ba377d63244e872f7aca43690 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 13 Nov 2025 15:47:14 +0100 Subject: [PATCH 14/15] feat: export * from AudioPlayer --- src/components/AudioPlayback/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/AudioPlayback/index.ts b/src/components/AudioPlayback/index.ts index 8ee5c7bfad..566e397fb8 100644 --- a/src/components/AudioPlayback/index.ts +++ b/src/components/AudioPlayback/index.ts @@ -1,4 +1,4 @@ -export { type AudioPlayerState } from './AudioPlayer'; +export * from './AudioPlayer'; export * from './plugins'; export { useActiveAudioPlayer, From 7118125727421f31d7ac868fd69840440d276ed8 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 14 Nov 2025 10:16:58 +0100 Subject: [PATCH 15/15] docs: add docstrings to AudioPlayerState --- src/components/AudioPlayback/AudioPlayer.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/components/AudioPlayback/AudioPlayer.ts b/src/components/AudioPlayback/AudioPlayer.ts index 93a0350e1b..94ea3a119c 100644 --- a/src/components/AudioPlayback/AudioPlayer.ts +++ b/src/components/AudioPlayback/AudioPlayer.ts @@ -14,7 +14,7 @@ export type RegisterAudioPlayerErrorParams = { errCode?: AudioPlayerErrorCode; }; -export type AudioDescriptor = { +export type AudioPlayerDescriptor = { id: string; src: string; /** Audio duration in seconds. */ @@ -31,19 +31,25 @@ export type AudioPlayerPlayAudioParams = { }; export type AudioPlayerState = { + /** Signals whether the browser can play the record. */ canPlayRecord: boolean; /** Current playback speed. Initiated with the first item of the playbackRates array. */ currentPlaybackRate: number; + /** The audio element ref */ elementRef: HTMLAudioElement | null; + /** Signals whether the playback is in progress. */ isPlaying: boolean; + /** Keeps the latest playback error reference. */ playbackError: Error | null; /** An array of fractional numeric values of playback speed to override the defaults (1.0, 1.5, 2.0) */ playbackRates: number[]; + /** Playback progress expressed in percent. */ progressPercent: number; + /** Playback progress expressed in seconds. */ secondsElapsed: number; }; -export type AudioPlayerOptions = AudioDescriptor & { +export type AudioPlayerOptions = AudioPlayerDescriptor & { /** An array of fractional numeric values of playback speed to override the defaults (1.0, 1.5, 2.0) */ playbackRates?: number[]; plugins?: AudioPlayerPlugin[]; @@ -65,12 +71,15 @@ export const defaultRegisterAudioPlayerError = ({ export const elementIsPlaying = (audioElement: HTMLAudioElement | null) => audioElement && !(audioElement.paused || audioElement.ended); -export type SeekFn = (params: { clientX: number; currentTarget: HTMLDivElement }) => void; +export type SeekFn = (params: { + clientX: number; + currentTarget: HTMLDivElement; +}) => Promise; export class AudioPlayer { state: StateStore; /** The audio MIME type that is checked before the audio is played. If the type is not supported the controller registers error in playbackError. */ - private _data: AudioDescriptor; + private _data: AudioPlayerDescriptor; private _plugins = new Map(); private playTimeout: ReturnType | undefined = undefined; private unsubscribeEventListeners: (() => void) | null = null; @@ -272,7 +281,7 @@ export class AudioPlayer { } }; - setDescriptor(descriptor: AudioDescriptor) { + setDescriptor(descriptor: AudioPlayerDescriptor) { this._data = { ...this._data, ...descriptor }; if (descriptor.src !== this.src && this.elementRef) { this.elementRef.src = descriptor.src;