Audio Playback

As of the version stream-chat-react@13.12.0 it is possible to build audio player components with the following combo of components:

  • WithAudioPlayback React component
  • useAudioPlayer hook
  • useActiveAudioPlayer hook
  • useAudioController hook (deprecated)
Until the version 13.12.0, the audio was played using `useAudioController` hook. The audio element was located directly in the player UI component. That was limiting in scenarios like audio playback in VirtualizedMessageList. During the scroll, the `Message` component with `audio` element was removed and thus the playback stopped. This is not the case of `useAudioPlayer` hook which reaches into a central pool of audio elements.

In this document we will focus on working with the non-deprecated components.

All the audio playback is controlled using the combination of WithAudioPlayback React component and useAudioPlayer hook. Component WithAudioPlayback is necessary to initiate and maintain a pool of AudioPlayer instances (that hold audio element reference). The pool is not publicly accessible. We access individual AudioPlayer instances using useAudioPlayer hook instead.

By default, the SDK renders WithAudioPlayback component inside the Channel component. All the Channel component’s children can thus reach to one shared pool of AudioPlayer instances using useAudioPlayer hook.

Audio Playback Mode

We can decide, whether we want to allow only a single or multiple audios to be played at a time.

Single-playback mode - only a single audio can be reproduced at a time (default in Channel component). Concurrent-playback mode - multiple audios can be reproduced at the same time

It is possible to change the default (false), by setting allowConcurrentPlayback prop of WithAudioPlayback component.

Building Audio Components

If we wanted to build a React component separated from Channel and needed to play audio inside this component, we need to perform the following steps:

  1. Have a top-level component render WithAudioPlayback
const TopLevelComponent = ({children}) => {
  return (
    <WithAudioPlayback allowConcurrentPlayback={false}>
      {children}
    </WithAudioPlayback>
  );
};
  1. Have a child component that renders the audio player UI
import type { AudioPlayer, AudioPlayerDescriptor, AudioPlayerState } from 'stream-chat-react';
import {
  FileSizeIndicator,
  PlayButton,
  PlaybackRateButton,
  useAudioPlayer,
  useStateStore,
  WaveProgressBar,
} from 'stream-chat-react';
/*
have a mechanism that provides a stable id so that the component gets hold of a stable instance of AudioPlayer
(e.g. generated as a combination of a message id and message parent_id or anything that makes sense for your business logic)
 */
import { useStableComponentId } from './useStableComponentId';
import {
  FileIcon,
} from './yourComponents'

type AudioPlayerComponentProps = Omit<AudioPlayerDescriptor, 'id'> & {
  playbackRates: number[];
};

const AudioPlayerComponent = (props: AudioPlayerComponentProps) => {
  const componentID = useStableComponentId();
  
  // retrieve the AudioPlayer instance
  const audioPlayer = useAudioPlayer({
    durationSeconds: props.durationSeconds,
    fileSize: props.fileSize,
    mimeType: props.mimeType,
    playbackRates: props.playbackRates,
    requester: componentID,
    src: props.src,
    title: props.title,
    waveformData: props.waveformData,
  });

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

type AudioPlayerUIProps = {
  audioPlayer: AudioPlayer;
}

// we need to select relevant data from AudioPlayer state 
const audioPlayerStateSelector = (state: AudioPlayerState) => ({
  canPlayRecord: state.canPlayRecord,
  isPlaying: state.isPlaying,
  playbackRate: state.currentPlaybackRate,
  progress: state.progressPercent,
  secondsElapsed: state.secondsElapsed,
});

const AudioPlayerUI =  ({ audioPlayer }: AudioPlayerUIProps) => {
  // we subscribe to AudioPlayer state changes 
  const { canPlayRecord, isPlaying, playbackRate, progress, secondsElapsed } =
  useStateStore(audioPlayer.state, audioPlayerStateSelector);

  const displayedDuration = secondsElapsed || audioPlayer.durationSeconds;

  return (
    <div className='audio-player-root'>
      <PlayButton isPlaying={!!isPlaying} onClick={audioPlayer.togglePlay} />
      
        <div className='audio-player-body'>

          {audioPlayer.title && 
            <div className='audio-player-title'>{audioPlayer.title}</div>
          }

          {typeof displayedDuration === 'number' && 
            <div className='audio-player-duration'>{displayedDuration}</div>
          }

          {typeof audioPlayer.fileSize !== 'undefined' && 
            <FileSizeIndicator
              fileSize={audioPlayer.fileSize}
              maximumFractionDigits={0}
            />
          }

          {!!audioPlayer.waveformData &&
            <WaveProgressBar
              progress={progress}
              seek={audioPlayer.seek}
              waveformData={audioPlayer.waveformData}
            />
          }
          
          {isPlaying ? (
            <PlaybackRateButton
              disabled={!canPlayRecord}
              onClick={audioPlayer.increasePlaybackRate}
            >
              {playbackRate?.toFixed(1)}x
            </PlaybackRateButton>
          ) : (
            <FileIcon big={true} mimeType={audioPlayer.mimeType} size={40} />
          )}
          
        </div>
    </div>
  );
};

AudioPlayer

All the control of the audio playback is executed through AudioPlayer instance retrieved using useAudioPlayerHook. The audio playback state changes are then reflected through the AudioPlayer state which we subscribe to.

AudioPlayer API

These are the AudioPlayer methods needed to create a audio player UI widget:

audioPlayer.play(params)

Starts playing the audio.

Signature
(params?: {currentPlaybackRate?: number; playbackRates?: number[];}) => Promise<void>;

audioPlayer.pause()

Pauses the audio, keeps the current playback progress.

Signature
() => void;

audioPlayer.stop()

Pauses the audio and resets the playback state, including the progress.

Signature
() => void;

audioPlayer.togglePlay()

Starts playing if paused, pauses if playing

Signature
() => void;

audioPlayer.seek(params)

Navigates to the selected time in the track. The parameters:

  • clientX - X coordinate where in the progress bar has the user clicked
  • currentTarget - progress bar root element
Signature
(params: { clientX: number; currentTarget: HTMLDivElement }) => Promise<void>

AudioPlayer State

We can subscribe to AudioPlayer state changes as follows:

// determines which properties we want to observe
const audioPlayerStateSelector = (state: AudioPlayerState) => ({
  canPlayRecord: state.canPlayRecord,
  isPlaying: state.isPlaying,
  playbackRate: state.currentPlaybackRate,
  progress: state.progressPercent,
  secondsElapsed: state.secondsElapsed,
  // ...
});

const selectedState =
  useStateStore(audioPlayer.state, audioPlayerStateSelector);

The state has the following shape:

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;
};

Active AudioPlayer

When operating in single-playback mode it can make sense to have a global audio player UI that displays the playback of active audio player. For example if a user starts the playback from a message’s audio widget and scrolls away, we may want to show the global audio player UI from which the playback could still be controlled without having to return to the original message.

We can achieve this by using useActiveAudioPlayer hook. This hooks provides reactive value in form of AudioPlayer instance. The instance always corresponds to the currently active player.

import { useEffect, useState } from 'react';
import {
  useActiveAudioPlayer,
} from 'stream-chat-react';
import { AudioPlayerUI } from './MyAudioPlayerUI';

const Player = () => {
  const activePlayer = useActiveAudioPlayer();
  const [open, setOpen] = useState<boolean>(!!activePlayer);

  useEffect(() => {
    setOpen(!!activePlayer);
    
    if (!activePlayer) return;
    const element = activePlayer?.elementRef;
    if (!element) return;
    
    const handleEnded = () => {
      setOpen(false);
    };
    
    // optionally we can hide the player once the playback ended
    element.addEventListener('ended', handleEnded);
    return () => {
      element.removeEventListener('ended', handleEnded);
    };
  }, [activePlayer]);

  if (!activePlayer || !open) return null;

  return (
    <div>
      <AudioPlayerUI audioPlayer={activePlayer} />
      <button onClick={() => setOpen(false)}>Close</button>
    </div>
  );
};
© Getstream.io, Inc. All Rights Reserved.