Add simple play/pause controls
This commit is contained in:
parent
e079f64a16
commit
30e120284d
9 changed files with 263 additions and 0 deletions
|
@ -248,6 +248,7 @@
|
||||||
@import "./views/toasts/_AnalyticsToast.scss";
|
@import "./views/toasts/_AnalyticsToast.scss";
|
||||||
@import "./views/toasts/_NonUrgentEchoFailureToast.scss";
|
@import "./views/toasts/_NonUrgentEchoFailureToast.scss";
|
||||||
@import "./views/verification/_VerificationShowSas.scss";
|
@import "./views/verification/_VerificationShowSas.scss";
|
||||||
|
@import "./views/voice_messages/_PlayPauseButton.scss";
|
||||||
@import "./views/voice_messages/_Waveform.scss";
|
@import "./views/voice_messages/_Waveform.scss";
|
||||||
@import "./views/voip/_CallContainer.scss";
|
@import "./views/voip/_CallContainer.scss";
|
||||||
@import "./views/voip/_CallView.scss";
|
@import "./views/voip/_CallView.scss";
|
||||||
|
|
51
res/css/views/voice_messages/_PlayPauseButton.scss
Normal file
51
res/css/views/voice_messages/_PlayPauseButton.scss
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
4
res/img/element-icons/pause-symbol.svg
Normal file
4
res/img/element-icons/pause-symbol.svg
Normal 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 |
3
res/img/element-icons/play-symbol.svg
Normal file
3
res/img/element-icons/play-symbol.svg
Normal 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 |
|
@ -27,6 +27,7 @@ import LiveRecordingClock from "../voice_messages/LiveRecordingClock";
|
||||||
import {VoiceRecordingStore} from "../../../stores/VoiceRecordingStore";
|
import {VoiceRecordingStore} from "../../../stores/VoiceRecordingStore";
|
||||||
import {UPDATE_EVENT} from "../../../stores/AsyncStore";
|
import {UPDATE_EVENT} from "../../../stores/AsyncStore";
|
||||||
import PlaybackWaveform from "../voice_messages/PlaybackWaveform";
|
import PlaybackWaveform from "../voice_messages/PlaybackWaveform";
|
||||||
|
import PlayPauseButton from "../voice_messages/PlayPauseButton";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
@ -131,7 +132,13 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
||||||
waveform = <PlaybackWaveform recorder={this.state.recorder} />;
|
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}>
|
return <div className={classes}>
|
||||||
|
{playPause}
|
||||||
{clock}
|
{clock}
|
||||||
{waveform}
|
{waveform}
|
||||||
</div>;
|
</div>;
|
||||||
|
|
89
src/components/views/voice_messages/PlayPauseButton.tsx
Normal file
89
src/components/views/voice_messages/PlayPauseButton.tsx
Normal 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}
|
||||||
|
/>;
|
||||||
|
}
|
||||||
|
}
|
|
@ -899,6 +899,8 @@
|
||||||
"Incoming call": "Incoming call",
|
"Incoming call": "Incoming call",
|
||||||
"Decline": "Decline",
|
"Decline": "Decline",
|
||||||
"Accept": "Accept",
|
"Accept": "Accept",
|
||||||
|
"Pause": "Pause",
|
||||||
|
"Play": "Play",
|
||||||
"The other party cancelled the verification.": "The other party cancelled the verification.",
|
"The other party cancelled the verification.": "The other party cancelled the verification.",
|
||||||
"Verified!": "Verified!",
|
"Verified!": "Verified!",
|
||||||
"You've successfully verified this user.": "You've successfully verified this user.",
|
"You've successfully verified this user.": "You've successfully verified this user.",
|
||||||
|
|
97
src/voice/Playback.ts
Normal file
97
src/voice/Playback.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -26,6 +26,7 @@ import {Singleflight} from "../utils/Singleflight";
|
||||||
import {PayloadEvent, WORKLET_NAME} from "./consts";
|
import {PayloadEvent, WORKLET_NAME} from "./consts";
|
||||||
import {arrayFastClone} from "../utils/arrays";
|
import {arrayFastClone} from "../utils/arrays";
|
||||||
import {UPDATE_EVENT} from "../stores/AsyncStore";
|
import {UPDATE_EVENT} from "../stores/AsyncStore";
|
||||||
|
import {Playback} from "./Playback";
|
||||||
|
|
||||||
const CHANNELS = 1; // stereo isn't important
|
const CHANNELS = 1; // stereo isn't important
|
||||||
const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality.
|
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() {
|
public destroy() {
|
||||||
// noinspection JSIgnoredPromiseFromCall - not concerned about stop() being called async here
|
// noinspection JSIgnoredPromiseFromCall - not concerned about stop() being called async here
|
||||||
this.stop();
|
this.stop();
|
||||||
|
|
Loading…
Reference in a new issue