mirror of
https://github.com/element-hq/element-web
synced 2024-11-24 10:15:43 +03:00
Introduce basic audio playback control
This commit is contained in:
parent
470778cbb8
commit
dda60949c3
6 changed files with 203 additions and 20 deletions
96
src/components/views/audio_messages/AudioPlayer.tsx
Normal file
96
src/components/views/audio_messages/AudioPlayer.tsx
Normal 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} />
|
||||||
|
{/* easiest way to introduce a gap between the components */}
|
||||||
|
{ this.renderFileSize() }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PlaybackWaveform playback={this.props.playback} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
55
src/components/views/audio_messages/DurationClock.tsx
Normal file
55
src/components/views/audio_messages/DurationClock.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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}
|
||||||
/>;
|
/>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue