Add simple play/pause controls

pull/21833/head
Travis Ralston 2021-04-26 20:48:24 -06:00
parent e079f64a16
commit 30e120284d
9 changed files with 263 additions and 0 deletions

View File

@ -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";

View File

@ -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');
}
}

View File

@ -0,0 +1,4 @@
<svg width="10" height="12" viewBox="0 0 10 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 1C0 0.447715 0.447715 0 1 0H2C2.55228 0 3 0.447715 3 1V11C3 11.5523 2.55228 12 2 12H1C0.447715 12 0 11.5523 0 11V1Z" fill="#737D8C"/>
<path d="M7 1C7 0.447715 7.44772 0 8 0H9C9.55228 0 10 0.447715 10 1V11C10 11.5523 9.55228 12 9 12H8C7.44772 12 7 11.5523 7 11V1Z" fill="#737D8C"/>
</svg>

After

Width:  |  Height:  |  Size: 396 B

View File

@ -0,0 +1,3 @@
<svg width="13" height="16" viewBox="0 0 13 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 14.2104V1.78956C0 1.00724 0.857827 0.527894 1.5241 0.937906L11.6161 7.14834C12.2506 7.53883 12.2506 8.46117 11.6161 8.85166L1.5241 15.0621C0.857828 15.4721 0 14.9928 0 14.2104Z" fill="#737D8C"/>
</svg>

After

Width:  |  Height:  |  Size: 310 B

View File

@ -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<IProps,
waveform = <PlaybackWaveform recorder={this.state.recorder} />;
}
let playPause = null;
if (this.state.recordingPhase === RecordingState.Ended) {
playPause = <PlayPauseButton recorder={this.state.recorder} />;
}
return <div className={classes}>
{playPause}
{clock}
{waveform}
</div>;

View File

@ -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<IProps, IState> {
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 <AccessibleTooltipButton
className={classes}
title={isPlaying ? _t("Pause") : _t("Play")}
onClick={this.onClick}
disabled={isDisabled}
/>;
}
}

View File

@ -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.",

97
src/voice/Playback.ts Normal file
View File

@ -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();
}
}

View File

@ -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<Playback> {
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();