diff --git a/src/components/views/audio_messages/AudioPlayer.tsx b/src/components/views/audio_messages/AudioPlayer.tsx new file mode 100644 index 0000000000..82372aca74 --- /dev/null +++ b/src/components/views/audio_messages/AudioPlayer.tsx @@ -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 { + private playPauseRef: RefObject = 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
+
+ +
+ +   {/* easiest way to introduce a gap between the components */} + { this.renderFileSize() } +
+
+ +
+ } +} diff --git a/src/components/views/audio_messages/DurationClock.tsx b/src/components/views/audio_messages/DurationClock.tsx new file mode 100644 index 0000000000..f6271d1cf4 --- /dev/null +++ b/src/components/views/audio_messages/DurationClock.tsx @@ -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 { + 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 ; + } +} diff --git a/src/components/views/audio_messages/PlayPauseButton.tsx b/src/components/views/audio_messages/PlayPauseButton.tsx index 399cb169bb..7d881a10e5 100644 --- a/src/components/views/audio_messages/PlayPauseButton.tsx +++ b/src/components/views/audio_messages/PlayPauseButton.tsx @@ -21,7 +21,8 @@ import { _t } from "../../../languageHandler"; import { Playback, PlaybackState } from "../../../voice/Playback"; import classNames from "classnames"; -interface IProps { +// omitted props are handled by render function +interface IProps extends Omit, "title" | "onClick" | "disabled"> { // Playback instance to manipulate. Cannot change during the component lifecycle. playback: Playback; @@ -39,13 +40,19 @@ export default class PlayPauseButton extends React.PureComponent { super(props); } - private onClick = async () => { - await this.props.playback.toggle(); + private onClick = () => { + // noinspection JSIgnoredPromiseFromCall + this.toggle(); }; + public async toggle() { + await this.props.playback.toggle(); + } + public render(): ReactNode { - const isPlaying = this.props.playback.isPlaying; - const isDisabled = this.props.playbackPhase === PlaybackState.Decoding; + const { playback, playbackPhase, ...restProps } = this.props; + const isPlaying = playback.isPlaying; + const isDisabled = playbackPhase === PlaybackState.Decoding; const classes = classNames('mx_PlayPauseButton', { 'mx_PlayPauseButton_play': !isPlaying, 'mx_PlayPauseButton_pause': isPlaying, @@ -56,6 +63,7 @@ export default class PlayPauseButton extends React.PureComponent { title={isPlaying ? _t("Pause") : _t("Play")} onClick={this.onClick} disabled={isDisabled} + {...restProps} />; } } diff --git a/src/components/views/messages/MAudioBody.tsx b/src/components/views/messages/MAudioBody.tsx index ddcebd7a60..a8f2304fe9 100644 --- a/src/components/views/messages/MAudioBody.tsx +++ b/src/components/views/messages/MAudioBody.tsx @@ -15,16 +15,16 @@ limitations under the License. */ import React from "react"; -import {MatrixEvent} from "matrix-js-sdk/src/models/event"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; -import {Playback} from "../../../voice/Playback"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { Playback } from "../../../voice/Playback"; import MFileBody from "./MFileBody"; import InlineSpinner from '../elements/InlineSpinner'; -import {_t} from "../../../languageHandler"; -import {mediaFromContent} from "../../../customisations/Media"; -import {decryptFile} from "../../../utils/DecryptFile"; -import RecordingPlayback from "../audio_messages/RecordingPlayback"; -import {IMediaEventContent} from "../../../customisations/models/IMediaEventContent"; +import { _t } from "../../../languageHandler"; +import { mediaFromContent } from "../../../customisations/Media"; +import { decryptFile } from "../../../utils/DecryptFile"; +import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; +import AudioPlayer from "../audio_messages/AudioPlayer"; interface IProps { mxEvent: MatrixEvent; @@ -52,9 +52,9 @@ export default class MAudioBody extends React.PureComponent { try { const blob = await decryptFile(content.file); buffer = await blob.arrayBuffer(); - this.setState({decryptedBlob: blob}); + this.setState({ decryptedBlob: blob }); } catch (e) { - this.setState({error: e}); + this.setState({ error: e }); console.warn("Unable to decrypt audio message", e); return; // stop processing the audio file } @@ -62,7 +62,7 @@ export default class MAudioBody extends React.PureComponent { try { buffer = await media.downloadSource().then(r => r.blob()).then(r => r.arrayBuffer()); } catch (e) { - this.setState({error: e}); + this.setState({ error: e }); console.warn("Unable to download audio message", e); return; // stop processing the audio file } @@ -70,6 +70,7 @@ export default class MAudioBody extends React.PureComponent { // We should have a buffer to work with now: let's set it up const playback = new Playback(buffer); + playback.clockInfo.populatePlaceholdersFrom(this.props.mxEvent); this.setState({ playback }); // Note: the RecordingPlayback component will handle preparing the Playback class for us. } @@ -101,7 +102,7 @@ export default class MAudioBody extends React.PureComponent { // At this point we should have a playable state return ( - + ) diff --git a/src/voice/Playback.ts b/src/voice/Playback.ts index 61da435151..f1f91310b2 100644 --- a/src/voice/Playback.ts +++ b/src/voice/Playback.ts @@ -58,6 +58,7 @@ export class Playback extends EventEmitter implements IDestroyable { private resampledWaveform: number[]; private waveformObservable = new SimpleObservable(); private readonly clock: PlaybackClock; + private readonly fileSize: number; /** * 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) { 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.resampledWaveform = arrayFastResample(seedWaveform ?? DEFAULT_WAVEFORM, PLAYBACK_WAVEFORM_SAMPLES); this.waveformObservable.update(this.resampledWaveform); 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 * zero and one, inclusive. diff --git a/src/voice/PlaybackClock.ts b/src/voice/PlaybackClock.ts index d6d36e861f..9c2d36923f 100644 --- a/src/voice/PlaybackClock.ts +++ b/src/voice/PlaybackClock.ts @@ -14,8 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {SimpleObservable} from "matrix-widget-api"; -import {IDestroyable} from "../utils/IDestroyable"; +import { SimpleObservable } from "matrix-widget-api"; +import { IDestroyable } from "../utils/IDestroyable"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; // Because keeping track of time is sufficiently complicated... export class PlaybackClock implements IDestroyable { @@ -25,12 +26,13 @@ export class PlaybackClock implements IDestroyable { private observable = new SimpleObservable(); private timerId: number; private clipDuration = 0; + private placeholderDuration = 0; public constructor(private context: AudioContext) { } public get durationSeconds(): number { - return this.clipDuration; + return this.clipDuration || this.placeholderDuration; } 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. * This is to ensure the clock isn't skewed into thinking it is ~0.5s into