diff --git a/res/css/_components.scss b/res/css/_components.scss
index 253f97bf42..0179d146d1 100644
--- a/res/css/_components.scss
+++ b/res/css/_components.scss
@@ -248,6 +248,7 @@
@import "./views/toasts/_AnalyticsToast.scss";
@import "./views/toasts/_NonUrgentEchoFailureToast.scss";
@import "./views/verification/_VerificationShowSas.scss";
+@import "./views/voice_messages/_PlayPauseButton.scss";
@import "./views/voice_messages/_Waveform.scss";
@import "./views/voip/_CallContainer.scss";
@import "./views/voip/_CallView.scss";
diff --git a/res/css/views/voice_messages/_PlayPauseButton.scss b/res/css/views/voice_messages/_PlayPauseButton.scss
new file mode 100644
index 0000000000..0fd31be28a
--- /dev/null
+++ b/res/css/views/voice_messages/_PlayPauseButton.scss
@@ -0,0 +1,51 @@
+/*
+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.
+*/
+
+.mx_PlayPauseButton {
+ position: relative;
+ width: 32px;
+ height: 32px;
+ border-radius: 32px;
+ background-color: $primary-bg-color;
+
+ &::before {
+ content: '';
+ position: absolute; // sizing varies by icon
+ background-color: $muted-fg-color;
+ mask-repeat: no-repeat;
+ mask-size: contain;
+ }
+
+ &.mx_PlayPauseButton_disabled::before {
+ opacity: 0.5;
+ }
+
+ &.mx_PlayPauseButton_play::before {
+ width: 13px;
+ height: 16px;
+ top: 8px; // center
+ left: 12px; // center
+ mask-image: url('$(res)/img/element-icons/play-symbol.svg');
+ }
+
+ &.mx_PlayPauseButton_pause::before {
+ width: 10px;
+ height: 12px;
+ top: 10px; // center
+ left: 11px; // center
+ mask-image: url('$(res)/img/element-icons/pause-symbol.svg');
+ }
+}
diff --git a/res/img/element-icons/pause-symbol.svg b/res/img/element-icons/pause-symbol.svg
new file mode 100644
index 0000000000..293c0a10d8
--- /dev/null
+++ b/res/img/element-icons/pause-symbol.svg
@@ -0,0 +1,4 @@
+
diff --git a/res/img/element-icons/play-symbol.svg b/res/img/element-icons/play-symbol.svg
new file mode 100644
index 0000000000..339e20b729
--- /dev/null
+++ b/res/img/element-icons/play-symbol.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx
index 3fcafafd05..53dc7cc9c5 100644
--- a/src/components/views/rooms/VoiceRecordComposerTile.tsx
+++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx
@@ -27,6 +27,7 @@ import LiveRecordingClock from "../voice_messages/LiveRecordingClock";
import {VoiceRecordingStore} from "../../../stores/VoiceRecordingStore";
import {UPDATE_EVENT} from "../../../stores/AsyncStore";
import PlaybackWaveform from "../voice_messages/PlaybackWaveform";
+import PlayPauseButton from "../voice_messages/PlayPauseButton";
interface IProps {
room: Room;
@@ -131,7 +132,13 @@ export default class VoiceRecordComposerTile extends React.PureComponent;
}
+ let playPause = null;
+ if (this.state.recordingPhase === RecordingState.Ended) {
+ playPause = ;
+ }
+
return
+ {playPause}
{clock}
{waveform}
;
diff --git a/src/components/views/voice_messages/PlayPauseButton.tsx b/src/components/views/voice_messages/PlayPauseButton.tsx
new file mode 100644
index 0000000000..1339caf77f
--- /dev/null
+++ b/src/components/views/voice_messages/PlayPauseButton.tsx
@@ -0,0 +1,89 @@
+/*
+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 {VoiceRecording} from "../../../voice/VoiceRecording";
+import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
+import {_t} from "../../../languageHandler";
+import {Playback, PlaybackState} from "../../../voice/Playback";
+import classNames from "classnames";
+import {UPDATE_EVENT} from "../../../stores/AsyncStore";
+
+interface IProps {
+ recorder: VoiceRecording;
+}
+
+interface IState {
+ playback: Playback;
+ playbackPhase: PlaybackState;
+}
+
+/**
+ * Displays a play/pause button (activating the play/pause function of the recorder)
+ * to be displayed in reference to a recording.
+ */
+@replaceableComponent("views.voice_messages.PlayPauseButton")
+export default class PlayPauseButton extends React.PureComponent {
+ public constructor(props) {
+ super(props);
+ this.state = {
+ playback: null, // not ready yet
+ playbackPhase: PlaybackState.Decoding,
+ };
+ }
+
+ public async componentDidMount() {
+ const playback = await this.props.recorder.getPlayback();
+ playback.on(UPDATE_EVENT, this.onPlaybackState);
+ this.setState({
+ playback: playback,
+
+ // We know the playback is no longer decoding when we get here. It'll emit an update
+ // before we've bound a listener, so we just update the state here.
+ playbackPhase: PlaybackState.Stopped,
+ });
+ }
+
+ public componentWillUnmount() {
+ if (this.state.playback) this.state.playback.off(UPDATE_EVENT, this.onPlaybackState);
+ }
+
+ private onPlaybackState = (newState: PlaybackState) => {
+ this.setState({playbackPhase: newState});
+ };
+
+ private onClick = async () => {
+ if (!this.state.playback) return; // ignore for now
+ await this.state.playback.toggle();
+ };
+
+ public render() {
+ const isPlaying = this.state.playback?.isPlaying;
+ const isDisabled = this.state.playbackPhase === PlaybackState.Decoding;
+ const classes = classNames('mx_PlayPauseButton', {
+ 'mx_PlayPauseButton_play': !isPlaying,
+ 'mx_PlayPauseButton_pause': isPlaying,
+ 'mx_PlayPauseButton_disabled': isDisabled,
+ });
+ return ;
+ }
+}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index f592cc19cf..943b2291b3 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -899,6 +899,8 @@
"Incoming call": "Incoming call",
"Decline": "Decline",
"Accept": "Accept",
+ "Pause": "Pause",
+ "Play": "Play",
"The other party cancelled the verification.": "The other party cancelled the verification.",
"Verified!": "Verified!",
"You've successfully verified this user.": "You've successfully verified this user.",
diff --git a/src/voice/Playback.ts b/src/voice/Playback.ts
new file mode 100644
index 0000000000..0039113a57
--- /dev/null
+++ b/src/voice/Playback.ts
@@ -0,0 +1,97 @@
+/*
+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 EventEmitter from "events";
+import {UPDATE_EVENT} from "../stores/AsyncStore";
+
+export enum PlaybackState {
+ Decoding = "decoding",
+ Stopped = "stopped", // no progress on timeline
+ Paused = "paused", // some progress on timeline
+ Playing = "playing", // active progress through timeline
+}
+
+export class Playback extends EventEmitter {
+ private context: AudioContext;
+ private source: AudioBufferSourceNode;
+ private state = PlaybackState.Decoding;
+ private audioBuf: AudioBuffer;
+
+ constructor(private buf: ArrayBuffer) {
+ super();
+ this.context = new AudioContext();
+ }
+
+ public emit(event: PlaybackState, ...args: any[]): boolean {
+ this.state = event;
+ super.emit(event, ...args);
+ super.emit(UPDATE_EVENT, event, ...args);
+ return true; // we don't ever care if the event had listeners, so just return "yes"
+ }
+
+ public async prepare() {
+ this.audioBuf = await this.context.decodeAudioData(this.buf);
+ this.emit(PlaybackState.Stopped); // signal that we're not decoding anymore
+ }
+
+ public get currentState(): PlaybackState {
+ return this.state;
+ }
+
+ public get isPlaying(): boolean {
+ return this.currentState === PlaybackState.Playing;
+ }
+
+ private onPlaybackEnd = async () => {
+ await this.context.suspend();
+ this.emit(PlaybackState.Stopped);
+ };
+
+ public async play() {
+ // We can't restart a buffer source, so we need to create a new one if we hit the end
+ if (this.state === PlaybackState.Stopped) {
+ if (this.source) {
+ this.source.disconnect();
+ this.source.removeEventListener("ended", this.onPlaybackEnd);
+ }
+
+ this.source = this.context.createBufferSource();
+ this.source.connect(this.context.destination);
+ this.source.buffer = this.audioBuf;
+ this.source.start(); // start immediately
+ this.source.addEventListener("ended", this.onPlaybackEnd);
+ }
+
+ // We use the context suspend/resume functions because it allows us to pause a source
+ // node, but that still doesn't help us when the source node runs out (see above).
+ await this.context.resume();
+ this.emit(PlaybackState.Playing);
+ }
+
+ public async pause() {
+ await this.context.suspend();
+ this.emit(PlaybackState.Paused);
+ }
+
+ public async stop() {
+ await this.onPlaybackEnd();
+ }
+
+ public async toggle() {
+ if (this.isPlaying) await this.pause();
+ else await this.play();
+ }
+}
diff --git a/src/voice/VoiceRecording.ts b/src/voice/VoiceRecording.ts
index 68a944da51..692e317333 100644
--- a/src/voice/VoiceRecording.ts
+++ b/src/voice/VoiceRecording.ts
@@ -26,6 +26,7 @@ import {Singleflight} from "../utils/Singleflight";
import {PayloadEvent, WORKLET_NAME} from "./consts";
import {arrayFastClone} from "../utils/arrays";
import {UPDATE_EVENT} from "../stores/AsyncStore";
+import {Playback} from "./Playback";
const CHANNELS = 1; // stereo isn't important
const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality.
@@ -270,6 +271,14 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
});
}
+ public getPlayback(): Promise {
+ return Singleflight.for(this, "playback").do(async () => {
+ const playback = new Playback(this.buffer.buffer); // cast to ArrayBuffer proper
+ await playback.prepare();
+ return playback;
+ });
+ }
+
public destroy() {
// noinspection JSIgnoredPromiseFromCall - not concerned about stop() being called async here
this.stop();