Skip to content
This repository was archived by the owner on Oct 16, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/audio-player-controls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/fuselage': minor
---

Extracted the presentational, fully controlled `AudioPlayerControls` component from `AudioPlayer` and exported it. It renders the play/pause, seek, elapsed time, playback speed and optional download controls without owning an `<audio>` element or any playback state, so it can be driven by an external/shared media element. `AudioPlayer` keeps its previous self-contained behavior and now composes `AudioPlayerControls` internally, so existing usage is unchanged.
Comment thread
ggazzo marked this conversation as resolved.
Resolved
87 changes: 28 additions & 59 deletions packages/fuselage/src/components/AudioPlayer/AudioPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,10 @@ import { useMergedRefs, useResizeObserver } from '@rocket.chat/fuselage-hooks';
import type { TrackHTMLAttributes } from 'react';
import { useState, useRef, forwardRef } from 'react';

import { Box, Button, IconButton, Margins } from '../..';
import { Box } from '../..';
import { useOwnerDocument } from '../../contexts';
import { Slider } from '../Slider';

const getMaskTime = (durationTime: number) =>
new Date(durationTime * 1000)
.toISOString()
.slice(durationTime > 60 * 60 ? 11 : 14, 19);
import AudioPlayerControls from './AudioPlayerControls';

function forceDownload(
ownerDocument: Document,
Expand Down Expand Up @@ -150,60 +146,33 @@ const AudioPlayer = forwardRef<HTMLAudioElement, AudioPlayerProps>(
display='flex'
alignItems='center'
>
<IconButton
primary
medium
onClick={handlePlay}
aria-label={isPlaying ? pauseLabel : playLabel}
icon={isPlaying ? 'pause-shape-filled' : 'play-shape-filled'}
<AudioPlayerControls
isPlaying={isPlaying}
currentTime={currentTime}
durationTime={durationTime}
playbackSpeed={playbackSpeed}
onTogglePlay={handlePlay}
onSeek={(time) => {
if (audioRef.current) {
audioRef.current.currentTime = time;
}
}}
onChangePlaybackSpeed={handlePlaybackSpeedSingleControl}
download={download}
downloadHref={src}
onDownload={(e) => {
const { host } = new URL(src);
if (host !== ownerDocument.defaultView?.location.host) {
e.preventDefault();
forceDownload(ownerDocument, src);
}
}}
playLabel={playLabel}
pauseLabel={pauseLabel}
audioPlaybackRangeLabel={audioPlaybackRangeLabel}
changePlaybackSpeedLabel={changePlaybackSpeedLabel}
downloadAudioFileLabel={downloadAudioFileLabel}
/>
<Margins inline={8}>
<Box fontScale='p2' color='secondary-info'>
{isPlaying || currentTime > 0
? getMaskTime(currentTime)
: getMaskTime(durationTime)}
</Box>
<Box mi={16} w='full'>
<Slider
aria-label={audioPlaybackRangeLabel}
showOutput={false}
value={currentTime}
maxValue={durationTime}
onChange={(value) => {
if (audioRef.current) {
audioRef.current.currentTime = value;
}
}}
/>
</Box>

<Button
secondary
small
onClick={handlePlaybackSpeedSingleControl}
aria-label={changePlaybackSpeedLabel}
>
{playbackSpeed}x
</Button>
</Margins>
{download && (
<IconButton
primary
aria-label={downloadAudioFileLabel}
is='a'
href={src}
download
icon='download'
medium
onClick={(e) => {
const { host } = new URL(src);
if (host !== ownerDocument.defaultView?.location.host) {
e.preventDefault();
forceDownload(ownerDocument, src);
}
}}
/>
)}
<audio
style={{ display: 'none' }}
onTimeUpdate={(e) => {
Expand Down
101 changes: 101 additions & 0 deletions packages/fuselage/src/components/AudioPlayer/AudioPlayerControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { MouseEvent } from 'react';

import { Box, Button, IconButton, Margins } from '../..';
import { Slider } from '../Slider';

const getMaskTime = (durationTime: number) =>
new Date(durationTime * 1000)
.toISOString()
.slice(durationTime > 60 * 60 ? 11 : 14, 19);

export type AudioPlayerControlsProps = {
isPlaying: boolean;
currentTime: number;
durationTime: number;
playbackSpeed: number;
onTogglePlay: () => void;
onSeek: (time: number) => void;
onChangePlaybackSpeed: () => void;
download?: boolean;
downloadHref?: string;
onDownload?: (event: MouseEvent<HTMLElement>) => void;
playLabel?: string;
pauseLabel?: string;
audioPlaybackRangeLabel?: string;
changePlaybackSpeedLabel?: string;
downloadAudioFileLabel?: string;
};

/**
* Presentational, fully controlled audio controls (play/pause, seek, elapsed
* time, playback speed and optional download). It owns no `<audio>` element and
* no playback state, so it can be driven by any source — a local element or a
* shared/persistent one. `AudioPlayer` wraps this with its own element for the
* common self-contained case.
*/
const AudioPlayerControls = ({
isPlaying,
currentTime,
durationTime,
playbackSpeed,
onTogglePlay,
onSeek,
onChangePlaybackSpeed,
download = false,
downloadHref,
onDownload,
playLabel = 'Play',
pauseLabel = 'Pause',
audioPlaybackRangeLabel = 'Audio Playback Range',
changePlaybackSpeedLabel = 'Change Playback Speed',
downloadAudioFileLabel = 'Download Audio File',
}: AudioPlayerControlsProps) => (
<Box display='flex' alignItems='center' w='full'>
<IconButton
primary
medium
onClick={onTogglePlay}
aria-label={isPlaying ? pauseLabel : playLabel}
icon={isPlaying ? 'pause-shape-filled' : 'play-shape-filled'}
/>
<Margins inline={8}>
<Box fontScale='p2' color='secondary-info'>
{isPlaying || currentTime > 0
? getMaskTime(currentTime)
: getMaskTime(durationTime)}
</Box>
<Box mi={16} w='full'>
<Slider
aria-label={audioPlaybackRangeLabel}
showOutput={false}
value={currentTime}
maxValue={durationTime}
onChange={(value) => onSeek(Array.isArray(value) ? value[0] : value)}
/>
</Box>

<Button
secondary
small
onClick={onChangePlaybackSpeed}
aria-label={changePlaybackSpeedLabel}
>
{playbackSpeed}x
</Button>
</Margins>
{download && (
<IconButton
primary
medium
is='a'
href={downloadHref}
download
icon='download'
aria-label={downloadAudioFileLabel}
onClick={onDownload}
/>
)}
</Box>
);

export default AudioPlayerControls;
4 changes: 4 additions & 0 deletions packages/fuselage/src/components/AudioPlayer/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
export { default as AudioPlayer, type AudioPlayerProps } from './AudioPlayer';
export {
default as AudioPlayerControls,
type AudioPlayerControlsProps,
} from './AudioPlayerControls';
Loading