Move AudioPlayer to shared components (#30386)

* feat: add `PlayPauseButton` to storybook

* feat: add generic media body

* feat: add seekbar component

* chore: add ViewWrapper to help writing stories with vm

* refactor: move `formatBytes` from `formattingUtils` into shared component

* refactor: add `className` props to `Clock`

* feat: add new audio player component

* test(e2e): add screenshots for new shared components

* feat: add AudioPlayerViewModel

* feat: use new audio player in `MAudioBody`

* refactor: remove old audio player

* test(e2e): update existing tests

* refactor: remove unused `DurationClock`

* refactor: rename `SeekBar` into `LegacySeekBar`
This commit is contained in:
Florian Duros
2025-08-07 11:02:49 +02:00
committed by GitHub
parent f9a0a626a6
commit 8086262e04
72 changed files with 1684 additions and 244 deletions

View File

@@ -1,65 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021-2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type ReactNode } from "react";
import PlayPauseButton from "./PlayPauseButton";
import { formatBytes } from "../../../utils/FormattingUtils";
import DurationClock from "./DurationClock";
import { _t } from "../../../languageHandler";
import SeekBar from "./SeekBar";
import PlaybackClock from "./PlaybackClock";
import AudioPlayerBase from "./AudioPlayerBase";
import { PlaybackState } from "../../../audio/Playback";
export default class AudioPlayer extends AudioPlayerBase {
protected renderFileSize(): string | null {
const bytes = this.props.playback.sizeBytes;
if (!bytes) return null;
// Not translated here - we're just presenting the data which should already
// be translated if needed.
return `(${formatBytes(bytes)})`;
}
protected renderComponent(): ReactNode {
// tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard
// events for accessibility
return (
<div className="mx_MediaBody mx_AudioPlayer_container" tabIndex={0} onKeyDown={this.onKeyDown}>
<div className="mx_AudioPlayer_primaryContainer">
<PlayPauseButton
playback={this.props.playback}
playbackPhase={this.state.playbackPhase}
tabIndex={-1} // prevent tabbing into the button
ref={this.playPauseRef}
/>
<div className="mx_AudioPlayer_mediaInfo">
<span className="mx_AudioPlayer_mediaName">
{this.props.mediaName || _t("timeline|m.audio|unnamed_audio")}
</span>
<div className="mx_AudioPlayer_byline">
<DurationClock playback={this.props.playback} />
&nbsp; {/* easiest way to introduce a gap between the components */}
{this.renderFileSize()}
</div>
</div>
</div>
<div className="mx_AudioPlayer_seek">
<SeekBar
playback={this.props.playback}
tabIndex={-1} // prevent tabbing into the bar
disabled={this.state.playbackPhase === PlaybackState.Decoding}
ref={this.seekRef}
/>
<PlaybackClock playback={this.props.playback} defaultDisplaySeconds={0} />
</div>
</div>
);
}
}

View File

@@ -14,7 +14,7 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { _t } from "../../../languageHandler";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import type SeekBar from "./SeekBar";
import type LegacySeekBar from "./LegacySeekBar";
import type PlayPauseButton from "./PlayPauseButton";
export interface IProps {
@@ -31,7 +31,7 @@ interface IState {
}
export default abstract class AudioPlayerBase<T extends IProps = IProps> extends React.PureComponent<T, IState> {
protected seekRef = createRef<SeekBar>();
protected seekRef = createRef<LegacySeekBar>();
protected playPauseRef = createRef<PlayPauseButton>();
public constructor(props: T) {

View File

@@ -1,49 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { Clock } from "../../../shared-components/audio/Clock";
import { type Playback } from "../../../audio/Playback";
interface IProps {
playback: Playback;
}
interface IState {
durationSeconds: number;
}
/**
* A clock which shows a clip's maximum duration.
*/
export default class DurationClock extends React.PureComponent<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {
// we track the duration on state because we won't really know what the clip duration
// is until the first time update, and as a PureComponent we are trying to dedupe state
// updates as much as possible. This is just the easiest way to avoid a forceUpdate() or
// member property to track "did we get a duration".
durationSeconds: this.props.playback.clockInfo.durationSeconds,
};
}
public componentDidMount(): void {
this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate);
}
private onTimeUpdate = (time: number[]): void => {
this.setState({ durationSeconds: time[1] });
};
public render(): React.ReactNode {
return <Clock seconds={this.state.durationSeconds} />;
}
}

View File

@@ -35,7 +35,10 @@ interface ISeekCSS extends CSSProperties {
const ARROW_SKIP_SECONDS = 5; // arbitrary
export default class SeekBar extends React.PureComponent<IProps, IState> {
/**
* @deprecated Use {@link SeekBar} instead.
*/
export default class LegacySeekBar extends React.PureComponent<IProps, IState> {
// We use an animation frame request to avoid overly spamming prop updates, even if we aren't
// really using anything demanding on the CSS front.

View File

@@ -11,7 +11,7 @@ import React, { type ReactNode } from "react";
import PlayPauseButton from "./PlayPauseButton";
import PlaybackClock from "./PlaybackClock";
import AudioPlayerBase, { type IProps as IAudioPlayerBaseProps } from "./AudioPlayerBase";
import SeekBar from "./SeekBar";
import LegacySeekBar from "./LegacySeekBar";
import PlaybackWaveform from "./PlaybackWaveform";
import { PlaybackState } from "../../../audio/Playback";
@@ -49,7 +49,7 @@ export default class RecordingPlayback extends AudioPlayerBase<IProps> {
<>
<div className="mx_RecordingPlayback_timelineLayoutMiddle">
<PlaybackWaveform playback={this.props.playback} />
<SeekBar
<LegacySeekBar
playback={this.props.playback}
tabIndex={0} // allow keyboard users to fall into the seek bar
disabled={this.state.playbackPhase === PlaybackState.Decoding}

View File

@@ -14,7 +14,6 @@ import { type MediaEventContent } from "matrix-js-sdk/src/types";
import { type Playback } from "../../../audio/Playback";
import InlineSpinner from "../elements/InlineSpinner";
import { _t } from "../../../languageHandler";
import AudioPlayer from "../audio_messages/AudioPlayer";
import MFileBody from "./MFileBody";
import { type IBodyProps } from "./IBodyProps";
import { PlaybackManager } from "../../../audio/PlaybackManager";
@@ -22,10 +21,13 @@ import { isVoiceMessage } from "../../../utils/EventUtils";
import { PlaybackQueue } from "../../../audio/PlaybackQueue";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import MediaProcessingError from "./shared/MediaProcessingError";
import { AudioPlayerViewModel } from "../../../viewmodels/audio/AudioPlayerViewModel";
import { AudioPlayerView } from "../../../shared-components/audio/AudioPlayerView";
interface IState {
error?: boolean;
playback?: Playback;
audioPlayerVm?: AudioPlayerViewModel;
}
export default class MAudioBody extends React.PureComponent<IBodyProps, IState> {
@@ -61,7 +63,7 @@ export default class MAudioBody extends React.PureComponent<IBodyProps, IState>
// We should have a buffer to work with now: let's set it up
const playback = PlaybackManager.instance.createPlaybackInstance(buffer, waveform);
playback.clockInfo.populatePlaceholdersFrom(this.props.mxEvent);
this.setState({ playback });
this.setState({ playback, audioPlayerVm: new AudioPlayerViewModel({ playback, mediaName: content.body }) });
if (isVoiceMessage(this.props.mxEvent)) {
PlaybackQueue.forRoom(this.props.mxEvent.getRoomId()!).unsortedEnqueue(this.props.mxEvent, playback);
@@ -113,7 +115,7 @@ export default class MAudioBody extends React.PureComponent<IBodyProps, IState>
// At this point we should have a playable state
return (
<span className="mx_MAudioBody">
<AudioPlayer playback={this.state.playback} mediaName={this.props.mxEvent.getContent().body} />
{this.state.audioPlayerVm && <AudioPlayerView vm={this.state.audioPlayerVm} />}
{this.showFileBody && <MFileBody {...this.props} showGenericPlaceholder={false} />}
</span>
);