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