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:
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* 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 { type ChangeEvent, type KeyboardEvent } from "react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import {
|
||||
type AudioPlayerViewSnapshot,
|
||||
type AudioPlayerViewModel as AudioPlayerViewModelInterface,
|
||||
} from "../../shared-components/audio/AudioPlayerView";
|
||||
import { type Playback } from "../../audio/Playback";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import { percentageOf } from "../../shared-components/utils/numbers";
|
||||
import { getKeyBindingsManager } from "../../KeyBindingsManager";
|
||||
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
|
||||
import { BaseViewModel } from "../base/BaseViewModel";
|
||||
|
||||
/**
|
||||
* The number of seconds to skip when the user presses the left or right arrow keys.
|
||||
*/
|
||||
const ARROW_SKIP_SECONDS = 5;
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* The playback instance that manages the audio playback.
|
||||
*/
|
||||
playback: Playback;
|
||||
/**
|
||||
* Optional name of the media being played.
|
||||
*/
|
||||
mediaName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewModel for the audio player, providing the current state of the audio playback.
|
||||
* It listens to updates from the Playback instance and computes a snapshot.
|
||||
*/
|
||||
export class AudioPlayerViewModel
|
||||
extends BaseViewModel<AudioPlayerViewSnapshot, Props>
|
||||
implements AudioPlayerViewModelInterface
|
||||
{
|
||||
/**
|
||||
* Indicates if there was an error processing the audio file.
|
||||
* @private
|
||||
*/
|
||||
private error = false;
|
||||
|
||||
/**
|
||||
* Computes the snapshot of the audio player based on the current playback state.
|
||||
* This includes the media name, size in bytes, playback state, duration, percentage complete,
|
||||
* played seconds, and whether there was an error.
|
||||
* @param playback - The playback instance managing the audio playback.
|
||||
* @param mediaName - Optional name of the media being played.
|
||||
* @param error - Indicates if there was an error processing the audio file.
|
||||
*/
|
||||
private static readonly computeSnapshot = (
|
||||
playback: Playback,
|
||||
mediaName?: string,
|
||||
error = false,
|
||||
): AudioPlayerViewSnapshot => {
|
||||
const percentComplete = percentageOf(playback.timeSeconds, 0, playback.durationSeconds) * 100;
|
||||
|
||||
return {
|
||||
mediaName,
|
||||
sizeBytes: playback.sizeBytes,
|
||||
playbackState: playback.currentState,
|
||||
durationSeconds: playback.durationSeconds,
|
||||
percentComplete,
|
||||
playedSeconds: playback.timeSeconds,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
public constructor(props: Props) {
|
||||
super(props, AudioPlayerViewModel.computeSnapshot(props.playback, props.mediaName));
|
||||
|
||||
// Don't wait for the promise to complete - it will emit a progress update when it
|
||||
// is done, and it's not meant to take long anyhow.
|
||||
this.preparePlayback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the playback by calling the prepare method on the playback instance.
|
||||
* @private
|
||||
*/
|
||||
private async preparePlayback(): Promise<void> {
|
||||
try {
|
||||
await this.props.playback.prepare();
|
||||
} catch (e) {
|
||||
logger.error("Error processing audio file:", e, this.props.playback.currentState);
|
||||
this.error = true;
|
||||
this.setSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
protected addDownstreamSubscription(): void {
|
||||
this.props.playback.on(UPDATE_EVENT, this.setSnapshot);
|
||||
// There is no unsubscribe method in SimpleObservable
|
||||
this.props.playback.clockInfo.liveData.onUpdate(this.setSnapshot);
|
||||
}
|
||||
protected removeDownstreamSubscription(): void {
|
||||
this.props.playback.off(UPDATE_EVENT, this.setSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the snapshot and emits an update to subscribers.
|
||||
*/
|
||||
private readonly setSnapshot = (): void => {
|
||||
this.snapshot.set(AudioPlayerViewModel.computeSnapshot(this.props.playback, this.props.mediaName, this.error));
|
||||
};
|
||||
|
||||
public onKeyDown = (ev: KeyboardEvent<HTMLDivElement>): void => {
|
||||
let handled = true;
|
||||
const action = getKeyBindingsManager().getAccessibilityAction(ev);
|
||||
|
||||
switch (action) {
|
||||
case KeyBindingAction.Space:
|
||||
this.togglePlay();
|
||||
break;
|
||||
case KeyBindingAction.ArrowLeft:
|
||||
this.props.playback.skipTo(this.props.playback.timeSeconds - ARROW_SKIP_SECONDS);
|
||||
break;
|
||||
case KeyBindingAction.ArrowRight:
|
||||
this.props.playback.skipTo(this.props.playback.timeSeconds + ARROW_SKIP_SECONDS);
|
||||
break;
|
||||
default:
|
||||
handled = false;
|
||||
break;
|
||||
}
|
||||
|
||||
// stopPropagation() prevents the FocusComposer catch-all from triggering,
|
||||
// but we need to do it on key down instead of press (even though the user
|
||||
// interaction is typically on press).
|
||||
if (handled) {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
public togglePlay = async (): Promise<void> => {
|
||||
await this.props.playback.toggle();
|
||||
};
|
||||
|
||||
public onSeekbarChange = async (ev: ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
await this.props.playback.skipTo((Number(ev.target.value) / 100) * this.props.playback.durationSeconds);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user