Introduce basic audio playback control

This commit is contained in:
Travis Ralston 2021-06-21 16:18:39 -06:00
parent 470778cbb8
commit dda60949c3
6 changed files with 203 additions and 20 deletions

View file

@ -0,0 +1,96 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Playback, PlaybackState } from "../../../voice/Playback";
import React, { createRef, ReactNode, RefObject } from "react";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import PlaybackWaveform from "./PlaybackWaveform";
import PlayPauseButton from "./PlayPauseButton";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { formatBytes } from "../../../utils/FormattingUtils";
import DurationClock from "./DurationClock";
import { Key } from "../../../Keyboard";
interface IProps {
// Playback instance to render. Cannot change during component lifecycle: create
// an all-new component instead.
playback: Playback;
}
interface IState {
playbackPhase: PlaybackState;
}
@replaceableComponent("views.audio_messages.AudioPlayer")
export default class AudioPlayer extends React.PureComponent<IProps, IState> {
private playPauseRef: RefObject<PlayPauseButton> = createRef();
constructor(props: IProps) {
super(props);
this.state = {
playbackPhase: PlaybackState.Decoding, // default assumption
};
// We don't need to de-register: the class handles this for us internally
this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
// 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.
// noinspection JSIgnoredPromiseFromCall
this.props.playback.prepare();
}
private onPlaybackUpdate = (ev: PlaybackState) => {
this.setState({ playbackPhase: ev });
};
private onKeyPress = (ev: React.KeyboardEvent) => {
if (ev.key === Key.SPACE) {
ev.stopPropagation();
this.playPauseRef.current?.toggle();
}
};
protected renderFileSize(): string {
const bytes = this.props.playback.sizeBytes;
if (!bytes) return null;
// Not translated as these are units, and therefore universal
return `(${formatBytes(bytes)})`;
}
public render(): ReactNode {
// tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard
// events for accessibility
return <div className='mx_AudioPlayer_container' tabIndex={0} onKeyPress={this.onKeyPress}>
<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_byline'>
<DurationClock playback={this.props.playback} />
&nbsp; {/* easiest way to introduce a gap between the components */}
{ this.renderFileSize() }
</div>
</div>
<PlaybackWaveform playback={this.props.playback} />
</div>
}
}

View file

@ -0,0 +1,55 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Clock from "./Clock";
import { Playback } from "../../../voice/Playback";
interface IProps {
playback: Playback;
}
interface IState {
durationSeconds: number;
}
/**
* A clock which shows a clip's maximum duration.
*/
@replaceableComponent("views.audio_messages.DurationClock")
export default class DurationClock extends React.PureComponent<IProps, IState> {
public constructor(props) {
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,
};
this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate);
}
private onTimeUpdate = (time: number[]) => {
this.setState({durationSeconds: time[1]});
};
public render() {
return <Clock seconds={this.state.durationSeconds} />;
}
}

View file

@ -21,7 +21,8 @@ import { _t } from "../../../languageHandler";
import { Playback, PlaybackState } from "../../../voice/Playback"; import { Playback, PlaybackState } from "../../../voice/Playback";
import classNames from "classnames"; import classNames from "classnames";
interface IProps { // omitted props are handled by render function
interface IProps extends Omit<React.ComponentProps<typeof AccessibleTooltipButton>, "title" | "onClick" | "disabled"> {
// Playback instance to manipulate. Cannot change during the component lifecycle. // Playback instance to manipulate. Cannot change during the component lifecycle.
playback: Playback; playback: Playback;
@ -39,13 +40,19 @@ export default class PlayPauseButton extends React.PureComponent<IProps> {
super(props); super(props);
} }
private onClick = async () => { private onClick = () => {
await this.props.playback.toggle(); // noinspection JSIgnoredPromiseFromCall
this.toggle();
}; };
public async toggle() {
await this.props.playback.toggle();
}
public render(): ReactNode { public render(): ReactNode {
const isPlaying = this.props.playback.isPlaying; const { playback, playbackPhase, ...restProps } = this.props;
const isDisabled = this.props.playbackPhase === PlaybackState.Decoding; const isPlaying = playback.isPlaying;
const isDisabled = playbackPhase === PlaybackState.Decoding;
const classes = classNames('mx_PlayPauseButton', { const classes = classNames('mx_PlayPauseButton', {
'mx_PlayPauseButton_play': !isPlaying, 'mx_PlayPauseButton_play': !isPlaying,
'mx_PlayPauseButton_pause': isPlaying, 'mx_PlayPauseButton_pause': isPlaying,
@ -56,6 +63,7 @@ export default class PlayPauseButton extends React.PureComponent<IProps> {
title={isPlaying ? _t("Pause") : _t("Play")} title={isPlaying ? _t("Pause") : _t("Play")}
onClick={this.onClick} onClick={this.onClick}
disabled={isDisabled} disabled={isDisabled}
{...restProps}
/>; />;
} }
} }

View file

@ -15,16 +15,16 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import {MatrixEvent} from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import {Playback} from "../../../voice/Playback"; import { Playback } from "../../../voice/Playback";
import MFileBody from "./MFileBody"; import MFileBody from "./MFileBody";
import InlineSpinner from '../elements/InlineSpinner'; import InlineSpinner from '../elements/InlineSpinner';
import {_t} from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import {mediaFromContent} from "../../../customisations/Media"; import { mediaFromContent } from "../../../customisations/Media";
import {decryptFile} from "../../../utils/DecryptFile"; import { decryptFile } from "../../../utils/DecryptFile";
import RecordingPlayback from "../audio_messages/RecordingPlayback"; import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
import {IMediaEventContent} from "../../../customisations/models/IMediaEventContent"; import AudioPlayer from "../audio_messages/AudioPlayer";
interface IProps { interface IProps {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
@ -52,9 +52,9 @@ export default class MAudioBody extends React.PureComponent<IProps, IState> {
try { try {
const blob = await decryptFile(content.file); const blob = await decryptFile(content.file);
buffer = await blob.arrayBuffer(); buffer = await blob.arrayBuffer();
this.setState({decryptedBlob: blob}); this.setState({ decryptedBlob: blob });
} catch (e) { } catch (e) {
this.setState({error: e}); this.setState({ error: e });
console.warn("Unable to decrypt audio message", e); console.warn("Unable to decrypt audio message", e);
return; // stop processing the audio file return; // stop processing the audio file
} }
@ -62,7 +62,7 @@ export default class MAudioBody extends React.PureComponent<IProps, IState> {
try { try {
buffer = await media.downloadSource().then(r => r.blob()).then(r => r.arrayBuffer()); buffer = await media.downloadSource().then(r => r.blob()).then(r => r.arrayBuffer());
} catch (e) { } catch (e) {
this.setState({error: e}); this.setState({ error: e });
console.warn("Unable to download audio message", e); console.warn("Unable to download audio message", e);
return; // stop processing the audio file return; // stop processing the audio file
} }
@ -70,6 +70,7 @@ export default class MAudioBody extends React.PureComponent<IProps, IState> {
// We should have a buffer to work with now: let's set it up // We should have a buffer to work with now: let's set it up
const playback = new Playback(buffer); const playback = new Playback(buffer);
playback.clockInfo.populatePlaceholdersFrom(this.props.mxEvent);
this.setState({ playback }); this.setState({ playback });
// Note: the RecordingPlayback component will handle preparing the Playback class for us. // Note: the RecordingPlayback component will handle preparing the Playback class for us.
} }
@ -101,7 +102,7 @@ export default class MAudioBody extends React.PureComponent<IProps, IState> {
// At this point we should have a playable state // At this point we should have a playable state
return ( return (
<span className="mx_MAudioBody"> <span className="mx_MAudioBody">
<RecordingPlayback playback={this.state.playback} /> <AudioPlayer playback={this.state.playback} />
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} /> <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />
</span> </span>
) )

View file

@ -58,6 +58,7 @@ export class Playback extends EventEmitter implements IDestroyable {
private resampledWaveform: number[]; private resampledWaveform: number[];
private waveformObservable = new SimpleObservable<number[]>(); private waveformObservable = new SimpleObservable<number[]>();
private readonly clock: PlaybackClock; private readonly clock: PlaybackClock;
private readonly fileSize: number;
/** /**
* Creates a new playback instance from a buffer. * Creates a new playback instance from a buffer.
@ -67,12 +68,22 @@ export class Playback extends EventEmitter implements IDestroyable {
*/ */
constructor(private buf: ArrayBuffer, seedWaveform = DEFAULT_WAVEFORM) { constructor(private buf: ArrayBuffer, seedWaveform = DEFAULT_WAVEFORM) {
super(); super();
// Capture the file size early as reading the buffer will result in a 0-length buffer left behind
this.fileSize = this.buf.byteLength;
this.context = createAudioContext(); this.context = createAudioContext();
this.resampledWaveform = arrayFastResample(seedWaveform ?? DEFAULT_WAVEFORM, PLAYBACK_WAVEFORM_SAMPLES); this.resampledWaveform = arrayFastResample(seedWaveform ?? DEFAULT_WAVEFORM, PLAYBACK_WAVEFORM_SAMPLES);
this.waveformObservable.update(this.resampledWaveform); this.waveformObservable.update(this.resampledWaveform);
this.clock = new PlaybackClock(this.context); this.clock = new PlaybackClock(this.context);
} }
/**
* Size of the audio clip in bytes. May be zero if unknown. This is updated
* when the playback goes through phase changes.
*/
public get sizeBytes(): number {
return this.fileSize;
}
/** /**
* Stable waveform for the playback. Values are guaranteed to be between * Stable waveform for the playback. Values are guaranteed to be between
* zero and one, inclusive. * zero and one, inclusive.

View file

@ -14,8 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {SimpleObservable} from "matrix-widget-api"; import { SimpleObservable } from "matrix-widget-api";
import {IDestroyable} from "../utils/IDestroyable"; import { IDestroyable } from "../utils/IDestroyable";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
// Because keeping track of time is sufficiently complicated... // Because keeping track of time is sufficiently complicated...
export class PlaybackClock implements IDestroyable { export class PlaybackClock implements IDestroyable {
@ -25,12 +26,13 @@ export class PlaybackClock implements IDestroyable {
private observable = new SimpleObservable<number[]>(); private observable = new SimpleObservable<number[]>();
private timerId: number; private timerId: number;
private clipDuration = 0; private clipDuration = 0;
private placeholderDuration = 0;
public constructor(private context: AudioContext) { public constructor(private context: AudioContext) {
} }
public get durationSeconds(): number { public get durationSeconds(): number {
return this.clipDuration; return this.clipDuration || this.placeholderDuration;
} }
public set durationSeconds(val: number) { public set durationSeconds(val: number) {
@ -54,6 +56,16 @@ export class PlaybackClock implements IDestroyable {
} }
}; };
/**
* Populates default information about the audio clip from the event body.
* The placeholders will be overridden once known.
* @param {MatrixEvent} event The event to use for placeholders.
*/
public populatePlaceholdersFrom(event: MatrixEvent) {
const durationSeconds = Number(event.getContent()['info']?.['duration']);
if (Number.isFinite(durationSeconds)) this.placeholderDuration = durationSeconds;
}
/** /**
* Mark the time in the audio context where the clip starts/has been loaded. * Mark the time in the audio context where the clip starts/has been loaded.
* This is to ensure the clock isn't skewed into thinking it is ~0.5s into * This is to ensure the clock isn't skewed into thinking it is ~0.5s into