Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/ts/9
This commit is contained in:
commit
6693993d08
36 changed files with 855 additions and 260 deletions
124
src/components/views/audio_messages/AudioPlayer.tsx
Normal file
124
src/components/views/audio_messages/AudioPlayer.tsx
Normal file
|
@ -0,0 +1,124 @@
|
|||
/*
|
||||
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 PlayPauseButton from "./PlayPauseButton";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { formatBytes } from "../../../utils/FormattingUtils";
|
||||
import DurationClock from "./DurationClock";
|
||||
import { Key } from "../../../Keyboard";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SeekBar from "./SeekBar";
|
||||
import PlaybackClock from "./PlaybackClock";
|
||||
|
||||
interface IProps {
|
||||
// Playback instance to render. Cannot change during component lifecycle: create
|
||||
// an all-new component instead.
|
||||
playback: Playback;
|
||||
|
||||
mediaName: string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
playbackPhase: PlaybackState;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.audio_messages.AudioPlayer")
|
||||
export default class AudioPlayer extends React.PureComponent<IProps, IState> {
|
||||
private playPauseRef: RefObject<PlayPauseButton> = createRef();
|
||||
private seekRef: RefObject<SeekBar> = 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 onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
// stopPropagation() prevents the FocusComposer catch-all from triggering,
|
||||
// but we need to do it on key down instead of press (even though the user
|
||||
// interaction is typically on press).
|
||||
if (ev.key === Key.SPACE) {
|
||||
ev.stopPropagation();
|
||||
this.playPauseRef.current?.toggleState();
|
||||
} else if (ev.key === Key.ARROW_LEFT) {
|
||||
ev.stopPropagation();
|
||||
this.seekRef.current?.left();
|
||||
} else if (ev.key === Key.ARROW_RIGHT) {
|
||||
ev.stopPropagation();
|
||||
this.seekRef.current?.right();
|
||||
}
|
||||
};
|
||||
|
||||
protected renderFileSize(): string {
|
||||
const bytes = this.props.playback.sizeBytes;
|
||||
if (!bytes) return null;
|
||||
|
||||
// Not translated here - we're just presenting the data which should already
|
||||
// be translated if needed.
|
||||
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_MediaBody mx_AudioPlayer_container' tabIndex={0} onKeyDown={this.onKeyDown}>
|
||||
<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_mediaInfo'>
|
||||
<span className='mx_AudioPlayer_mediaName'>
|
||||
{this.props.mediaName || _t("Unnamed audio")}
|
||||
</span>
|
||||
<div className='mx_AudioPlayer_byline'>
|
||||
<DurationClock playback={this.props.playback} />
|
||||
{/* easiest way to introduce a gap between the components */}
|
||||
{ this.renderFileSize() }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mx_AudioPlayer_seek'>
|
||||
<SeekBar
|
||||
playback={this.props.playback}
|
||||
tabIndex={-1} // prevent tabbing into the bar
|
||||
playbackPhase={this.state.playbackPhase}
|
||||
ref={this.seekRef}
|
||||
/>
|
||||
<PlaybackClock playback={this.props.playback} defaultDisplaySeconds={0} />
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
|
@ -28,7 +28,7 @@ interface IState {
|
|||
* Simply converts seconds into minutes and seconds. Note that hours will not be
|
||||
* displayed, making it possible to see "82:29".
|
||||
*/
|
||||
@replaceableComponent("views.voice_messages.Clock")
|
||||
@replaceableComponent("views.audio_messages.Clock")
|
||||
export default class Clock extends React.Component<IProps, IState> {
|
||||
public constructor(props) {
|
||||
super(props);
|
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} />;
|
||||
}
|
||||
}
|
|
@ -1,9 +1,12 @@
|
|||
/*
|
||||
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.
|
||||
|
@ -12,16 +15,13 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import Clock from "./Clock";
|
||||
import { IRecordingUpdate, VoiceRecording } from "../../../voice/VoiceRecording";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Clock from "./Clock";
|
||||
import { MarkedExecution } from "../../../utils/MarkedExecution";
|
||||
import {
|
||||
IRecordingUpdate,
|
||||
VoiceRecording,
|
||||
} from "../../../voice/VoiceRecording";
|
||||
|
||||
interface IProps {
|
||||
recorder?: VoiceRecording;
|
||||
recorder: VoiceRecording;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -31,7 +31,7 @@ interface IState {
|
|||
/**
|
||||
* A clock for a live recording.
|
||||
*/
|
||||
@replaceableComponent("views.voice_messages.LiveRecordingClock")
|
||||
@replaceableComponent("views.audio_messages.LiveRecordingClock")
|
||||
export default class LiveRecordingClock extends React.PureComponent<IProps, IState> {
|
||||
private seconds = 0;
|
||||
private scheduledUpdate = new MarkedExecution(
|
|
@ -1,9 +1,12 @@
|
|||
/*
|
||||
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.
|
||||
|
@ -12,16 +15,15 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import Waveform from "./Waveform";
|
||||
import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../voice/VoiceRecording";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { arrayFastResample } from "../../../utils/arrays";
|
||||
import { percentageOf } from "../../../utils/numbers";
|
||||
import Waveform from "./Waveform";
|
||||
import { MarkedExecution } from "../../../utils/MarkedExecution";
|
||||
import {
|
||||
IRecordingUpdate,
|
||||
VoiceRecording,
|
||||
} from "../../../voice/VoiceRecording";
|
||||
|
||||
interface IProps {
|
||||
recorder?: VoiceRecording;
|
||||
recorder: VoiceRecording;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -31,7 +33,7 @@ interface IState {
|
|||
/**
|
||||
* A waveform which shows the waveform of a live recording
|
||||
*/
|
||||
@replaceableComponent("views.voice_messages.LiveRecordingWaveform")
|
||||
@replaceableComponent("views.audio_messages.LiveRecordingWaveform")
|
||||
export default class LiveRecordingWaveform extends React.PureComponent<IProps, IState> {
|
||||
public static defaultProps = {
|
||||
progress: 1,
|
||||
|
@ -52,15 +54,18 @@ export default class LiveRecordingWaveform extends React.PureComponent<IProps, I
|
|||
|
||||
componentDidMount() {
|
||||
this.props.recorder.liveData.onUpdate((update: IRecordingUpdate) => {
|
||||
this.waveform = update.waveform;
|
||||
const bars = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES);
|
||||
// The incoming data is between zero and one, but typically even screaming into a
|
||||
// microphone won't send you over 0.6, so we artificially adjust the gain for the
|
||||
// waveform. This results in a slightly more cinematic/animated waveform for the
|
||||
// user.
|
||||
this.waveform = bars.map(b => percentageOf(b, 0, 0.50));
|
||||
this.scheduledUpdate.mark();
|
||||
});
|
||||
}
|
||||
|
||||
private updateWaveform() {
|
||||
this.setState({
|
||||
waveform: this.waveform,
|
||||
});
|
||||
this.setState({ waveform: this.waveform });
|
||||
}
|
||||
|
||||
public render() {
|
|
@ -21,7 +21,8 @@ import { _t } from "../../../languageHandler";
|
|||
import { Playback, PlaybackState } from "../../../voice/Playback";
|
||||
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: Playback;
|
||||
|
||||
|
@ -33,19 +34,25 @@ interface IProps {
|
|||
* 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")
|
||||
@replaceableComponent("views.audio_messages.PlayPauseButton")
|
||||
export default class PlayPauseButton extends React.PureComponent<IProps> {
|
||||
public constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
private onClick = async () => {
|
||||
await this.props.playback.toggle();
|
||||
private onClick = () => {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.toggleState();
|
||||
};
|
||||
|
||||
public async toggleState() {
|
||||
await this.props.playback.toggle();
|
||||
}
|
||||
|
||||
public render(): ReactNode {
|
||||
const isPlaying = this.props.playback.isPlaying;
|
||||
const isDisabled = this.props.playbackPhase === PlaybackState.Decoding;
|
||||
const { playback, playbackPhase, ...restProps } = this.props;
|
||||
const isPlaying = playback.isPlaying;
|
||||
const isDisabled = playbackPhase === PlaybackState.Decoding;
|
||||
const classes = classNames('mx_PlayPauseButton', {
|
||||
'mx_PlayPauseButton_play': !isPlaying,
|
||||
'mx_PlayPauseButton_pause': isPlaying,
|
||||
|
@ -56,6 +63,7 @@ export default class PlayPauseButton extends React.PureComponent<IProps> {
|
|||
title={isPlaying ? _t("Pause") : _t("Play")}
|
||||
onClick={this.onClick}
|
||||
disabled={isDisabled}
|
||||
{...restProps}
|
||||
/>;
|
||||
}
|
||||
}
|
|
@ -22,6 +22,11 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
|||
|
||||
interface IProps {
|
||||
playback: Playback;
|
||||
|
||||
// The default number of seconds to show when the playback has completed or
|
||||
// has not started. Not used during playback, even when paused. Defaults to
|
||||
// clip duration length.
|
||||
defaultDisplaySeconds?: number;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -33,7 +38,7 @@ interface IState {
|
|||
/**
|
||||
* A clock for a playback of a recording.
|
||||
*/
|
||||
@replaceableComponent("views.voice_messages.PlaybackClock")
|
||||
@replaceableComponent("views.audio_messages.PlaybackClock")
|
||||
export default class PlaybackClock extends React.PureComponent<IProps, IState> {
|
||||
public constructor(props) {
|
||||
super(props);
|
||||
|
@ -64,7 +69,11 @@ export default class PlaybackClock extends React.PureComponent<IProps, IState> {
|
|||
public render() {
|
||||
let seconds = this.state.seconds;
|
||||
if (this.state.playbackPhase === PlaybackState.Stopped) {
|
||||
seconds = this.state.durationSeconds;
|
||||
if (Number.isFinite(this.props.defaultDisplaySeconds)) {
|
||||
seconds = this.props.defaultDisplaySeconds;
|
||||
} else {
|
||||
seconds = this.state.durationSeconds;
|
||||
}
|
||||
}
|
||||
return <Clock seconds={seconds} />;
|
||||
}
|
|
@ -33,7 +33,7 @@ interface IState {
|
|||
/**
|
||||
* A waveform which shows the waveform of a previously recorded recording
|
||||
*/
|
||||
@replaceableComponent("views.voice_messages.PlaybackWaveform")
|
||||
@replaceableComponent("views.audio_messages.PlaybackWaveform")
|
||||
export default class PlaybackWaveform extends React.PureComponent<IProps, IState> {
|
||||
public constructor(props) {
|
||||
super(props);
|
|
@ -20,6 +20,7 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
|||
import PlaybackWaveform from "./PlaybackWaveform";
|
||||
import PlayPauseButton from "./PlayPauseButton";
|
||||
import PlaybackClock from "./PlaybackClock";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
interface IProps {
|
||||
// Playback instance to render. Cannot change during component lifecycle: create
|
||||
|
@ -31,6 +32,7 @@ interface IState {
|
|||
playbackPhase: PlaybackState;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.audio_messages.RecordingPlayback")
|
||||
export default class RecordingPlayback extends React.PureComponent<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
@ -53,7 +55,7 @@ export default class RecordingPlayback extends React.PureComponent<IProps, IStat
|
|||
};
|
||||
|
||||
public render(): ReactNode {
|
||||
return <div className='mx_VoiceMessagePrimaryContainer'>
|
||||
return <div className='mx_MediaBody mx_VoiceMessagePrimaryContainer'>
|
||||
<PlayPauseButton playback={this.props.playback} playbackPhase={this.state.playbackPhase} />
|
||||
<PlaybackClock playback={this.props.playback} />
|
||||
<PlaybackWaveform playback={this.props.playback} />
|
112
src/components/views/audio_messages/SeekBar.tsx
Normal file
112
src/components/views/audio_messages/SeekBar.tsx
Normal file
|
@ -0,0 +1,112 @@
|
|||
/*
|
||||
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, { ChangeEvent, CSSProperties, ReactNode } from "react";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { MarkedExecution } from "../../../utils/MarkedExecution";
|
||||
import { percentageOf } from "../../../utils/numbers";
|
||||
|
||||
interface IProps {
|
||||
// Playback instance to render. Cannot change during component lifecycle: create
|
||||
// an all-new component instead.
|
||||
playback: Playback;
|
||||
|
||||
// Tab index for the underlying component. Useful if the seek bar is in a managed state.
|
||||
// Defaults to zero.
|
||||
tabIndex?: number;
|
||||
|
||||
playbackPhase: PlaybackState;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
interface ISeekCSS extends CSSProperties {
|
||||
'--fillTo': number;
|
||||
}
|
||||
|
||||
const ARROW_SKIP_SECONDS = 5; // arbitrary
|
||||
|
||||
@replaceableComponent("views.audio_messages.SeekBar")
|
||||
export default class SeekBar extends React.PureComponent<IProps, IState> {
|
||||
// We use an animation frame request to avoid overly spamming prop updates, even if we aren't
|
||||
// really using anything demanding on the CSS front.
|
||||
|
||||
private animationFrameFn = new MarkedExecution(
|
||||
() => this.doUpdate(),
|
||||
() => requestAnimationFrame(() => this.animationFrameFn.trigger()));
|
||||
|
||||
public static defaultProps = {
|
||||
tabIndex: 0,
|
||||
};
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
percentage: 0,
|
||||
};
|
||||
|
||||
// We don't need to de-register: the class handles this for us internally
|
||||
this.props.playback.clockInfo.liveData.onUpdate(() => this.animationFrameFn.mark());
|
||||
}
|
||||
|
||||
private doUpdate() {
|
||||
this.setState({
|
||||
percentage: percentageOf(
|
||||
this.props.playback.clockInfo.timeSeconds,
|
||||
0,
|
||||
this.props.playback.clockInfo.durationSeconds),
|
||||
});
|
||||
}
|
||||
|
||||
public left() {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.props.playback.skipTo(this.props.playback.clockInfo.timeSeconds - ARROW_SKIP_SECONDS);
|
||||
}
|
||||
|
||||
public right() {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.props.playback.skipTo(this.props.playback.clockInfo.timeSeconds + ARROW_SKIP_SECONDS);
|
||||
}
|
||||
|
||||
private onChange = (ev: ChangeEvent<HTMLInputElement>) => {
|
||||
// Thankfully, onChange is only called when the user changes the value, not when we
|
||||
// change the value on the component. We can use this as a reliable "skip to X" function.
|
||||
//
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.props.playback.skipTo(Number(ev.target.value) * this.props.playback.clockInfo.durationSeconds);
|
||||
};
|
||||
|
||||
public render(): ReactNode {
|
||||
// We use a range input to avoid having to re-invent accessibility handling on
|
||||
// a custom set of divs.
|
||||
return <input
|
||||
type="range"
|
||||
className='mx_SeekBar'
|
||||
tabIndex={this.props.tabIndex}
|
||||
onChange={this.onChange}
|
||||
min={0}
|
||||
max={1}
|
||||
value={this.state.percentage}
|
||||
step={0.001}
|
||||
style={{ '--fillTo': this.state.percentage } as ISeekCSS}
|
||||
disabled={this.props.playbackPhase === PlaybackState.Decoding}
|
||||
/>;
|
||||
}
|
||||
}
|
|
@ -17,8 +17,13 @@ limitations under the License.
|
|||
import React from "react";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import classNames from "classnames";
|
||||
import { CSSProperties } from "react";
|
||||
|
||||
export interface IProps {
|
||||
interface WaveformCSSProperties extends CSSProperties {
|
||||
'--barHeight': number;
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
relHeights: number[]; // relative heights (0-1)
|
||||
progress: number; // percent complete, 0-1, default 100%
|
||||
}
|
||||
|
@ -34,14 +39,7 @@ interface IState {
|
|||
* For CSS purposes, a mx_Waveform_bar_100pct class is added when the bar should be
|
||||
* "filled", as a demonstration of the progress property.
|
||||
*/
|
||||
|
||||
import { CSSProperties } from "react";
|
||||
|
||||
export interface WaveformCSSProperties extends CSSProperties {
|
||||
'--barHeight': number;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.voice_messages.Waveform")
|
||||
@replaceableComponent("views.audio_messages.Waveform")
|
||||
export default class Waveform extends React.PureComponent<IProps, IState> {
|
||||
public static defaultProps = {
|
||||
progress: 1,
|
|
@ -1,112 +0,0 @@
|
|||
/*
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
|
||||
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 MFileBody from './MFileBody';
|
||||
|
||||
import { decryptFile } from '../../../utils/DecryptFile';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import InlineSpinner from '../elements/InlineSpinner';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { mediaFromContent } from "../../../customisations/Media";
|
||||
|
||||
@replaceableComponent("views.messages.MAudioBody")
|
||||
export default class MAudioBody extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
playing: false,
|
||||
decryptedUrl: null,
|
||||
decryptedBlob: null,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
onPlayToggle() {
|
||||
this.setState({
|
||||
playing: !this.state.playing,
|
||||
});
|
||||
}
|
||||
|
||||
_getContentUrl() {
|
||||
const media = mediaFromContent(this.props.mxEvent.getContent());
|
||||
if (media.isEncrypted) {
|
||||
return this.state.decryptedUrl;
|
||||
} else {
|
||||
return media.srcHttp;
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const content = this.props.mxEvent.getContent();
|
||||
if (content.file !== undefined && this.state.decryptedUrl === null) {
|
||||
let decryptedBlob;
|
||||
decryptFile(content.file).then(function(blob) {
|
||||
decryptedBlob = blob;
|
||||
return URL.createObjectURL(decryptedBlob);
|
||||
}).then((url) => {
|
||||
this.setState({
|
||||
decryptedUrl: url,
|
||||
decryptedBlob: decryptedBlob,
|
||||
});
|
||||
}, (err) => {
|
||||
console.warn("Unable to decrypt attachment: ", err);
|
||||
this.setState({
|
||||
error: err,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.state.decryptedUrl) {
|
||||
URL.revokeObjectURL(this.state.decryptedUrl);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const content = this.props.mxEvent.getContent();
|
||||
|
||||
if (this.state.error !== null) {
|
||||
return (
|
||||
<span className="mx_MAudioBody">
|
||||
<img src={require("../../../../res/img/warning.svg")} width="16" height="16" />
|
||||
{ _t("Error decrypting audio") }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (content.file !== undefined && this.state.decryptedUrl === null) {
|
||||
// Need to decrypt the attachment
|
||||
// The attachment is decrypted in componentDidMount.
|
||||
// For now add an img tag with a 16x16 spinner.
|
||||
// Not sure how tall the audio player is so not sure how tall it should actually be.
|
||||
return (
|
||||
<span className="mx_MAudioBody">
|
||||
<InlineSpinner />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const contentUrl = this._getContentUrl();
|
||||
|
||||
return (
|
||||
<span className="mx_MAudioBody">
|
||||
<audio src={contentUrl} controls />
|
||||
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
110
src/components/views/messages/MAudioBody.tsx
Normal file
110
src/components/views/messages/MAudioBody.tsx
Normal file
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
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 { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { Playback } from "../../../voice/Playback";
|
||||
import MFileBody from "./MFileBody";
|
||||
import InlineSpinner from '../elements/InlineSpinner';
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { mediaFromContent } from "../../../customisations/Media";
|
||||
import { decryptFile } from "../../../utils/DecryptFile";
|
||||
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
|
||||
import AudioPlayer from "../audio_messages/AudioPlayer";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
error?: Error;
|
||||
playback?: Playback;
|
||||
decryptedBlob?: Blob;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.MAudioBody")
|
||||
export default class MAudioBody extends React.PureComponent<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
public async componentDidMount() {
|
||||
let buffer: ArrayBuffer;
|
||||
const content: IMediaEventContent = this.props.mxEvent.getContent();
|
||||
const media = mediaFromContent(content);
|
||||
if (media.isEncrypted) {
|
||||
try {
|
||||
const blob = await decryptFile(content.file);
|
||||
buffer = await blob.arrayBuffer();
|
||||
this.setState({ decryptedBlob: blob });
|
||||
} catch (e) {
|
||||
this.setState({ error: e });
|
||||
console.warn("Unable to decrypt audio message", e);
|
||||
return; // stop processing the audio file
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
buffer = await media.downloadSource().then(r => r.blob()).then(r => r.arrayBuffer());
|
||||
} catch (e) {
|
||||
this.setState({ error: e });
|
||||
console.warn("Unable to download audio message", e);
|
||||
return; // stop processing the audio file
|
||||
}
|
||||
}
|
||||
|
||||
// We should have a buffer to work with now: let's set it up
|
||||
const playback = new Playback(buffer);
|
||||
playback.clockInfo.populatePlaceholdersFrom(this.props.mxEvent);
|
||||
this.setState({ playback });
|
||||
// Note: the RecordingPlayback component will handle preparing the Playback class for us.
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.state.playback?.destroy();
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.error) {
|
||||
// TODO: @@TR: Verify error state
|
||||
return (
|
||||
<span className="mx_MAudioBody">
|
||||
<img src={require("../../../../res/img/warning.svg")} width="16" height="16" />
|
||||
{ _t("Error processing audio message") }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.state.playback) {
|
||||
// TODO: @@TR: Verify loading/decrypting state
|
||||
return (
|
||||
<span className="mx_MAudioBody">
|
||||
<InlineSpinner />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// At this point we should have a playable state
|
||||
return (
|
||||
<span className="mx_MAudioBody">
|
||||
<AudioPlayer playback={this.state.playback} mediaName={this.props.mxEvent.getContent().body} />
|
||||
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -42,8 +42,7 @@ export default class MStickerBody extends MImageBody {
|
|||
// Placeholder to show in place of the sticker image if
|
||||
// img onLoad hasn't fired yet.
|
||||
getPlaceholder() {
|
||||
const TintableSVG = sdk.getComponent('elements.TintableSvg');
|
||||
return <TintableSVG src={require("../../../../res/img/icons-show-stickers.svg")} width="75" height="75" />;
|
||||
return <img src={require("../../../../res/img/icons-show-stickers.svg")} width="75" height="75" />;
|
||||
}
|
||||
|
||||
// Tooltip to show on mouse over
|
||||
|
|
|
@ -23,7 +23,7 @@ import InlineSpinner from '../elements/InlineSpinner';
|
|||
import { _t } from "../../../languageHandler";
|
||||
import { mediaFromContent } from "../../../customisations/Media";
|
||||
import { decryptFile } from "../../../utils/DecryptFile";
|
||||
import RecordingPlayback from "../voice_messages/RecordingPlayback";
|
||||
import RecordingPlayback from "../audio_messages/RecordingPlayback";
|
||||
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
|
||||
|
||||
interface IProps {
|
||||
|
|
|
@ -27,7 +27,7 @@ export default class SimpleRoomHeader extends React.Component {
|
|||
static propTypes = {
|
||||
title: PropTypes.string,
|
||||
|
||||
// `src` to a TintableSvg. Optional.
|
||||
// `src` to an image. Optional.
|
||||
icon: PropTypes.string,
|
||||
};
|
||||
|
||||
|
|
|
@ -18,22 +18,18 @@ import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
|||
import { _t } from "../../../languageHandler";
|
||||
import React, { ReactNode } from "react";
|
||||
import {
|
||||
IRecordingUpdate,
|
||||
RECORDING_PLAYBACK_SAMPLES,
|
||||
RecordingState,
|
||||
VoiceRecording,
|
||||
} from "../../../voice/VoiceRecording";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import classNames from "classnames";
|
||||
import LiveRecordingWaveform from "../voice_messages/LiveRecordingWaveform";
|
||||
import LiveRecordingWaveform from "../audio_messages/LiveRecordingWaveform";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { arrayFastResample, arraySeed } from "../../../utils/arrays";
|
||||
import { percentageOf } from "../../../utils/numbers";
|
||||
import LiveRecordingClock from "../voice_messages/LiveRecordingClock";
|
||||
import LiveRecordingClock from "../audio_messages/LiveRecordingClock";
|
||||
import { VoiceRecordingStore } from "../../../stores/VoiceRecordingStore";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
import RecordingPlayback from "../voice_messages/RecordingPlayback";
|
||||
import RecordingPlayback from "../audio_messages/RecordingPlayback";
|
||||
import { MsgType } from "matrix-js-sdk/src/@types/event";
|
||||
import Modal from "../../../Modal";
|
||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||
|
@ -46,8 +42,6 @@ interface IProps {
|
|||
interface IState {
|
||||
recorder?: VoiceRecording;
|
||||
recordingPhase?: RecordingState;
|
||||
relHeights: number[];
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -55,58 +49,18 @@ interface IState {
|
|||
*/
|
||||
@replaceableComponent("views.rooms.VoiceRecordComposerTile")
|
||||
export default class VoiceRecordComposerTile extends React.PureComponent<IProps, IState> {
|
||||
private waveform: number[] = [];
|
||||
private seconds = 0;
|
||||
private scheduledAnimationFrame = false;
|
||||
|
||||
public constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
recorder: null, // no recording started by default
|
||||
seconds: 0,
|
||||
relHeights: arraySeed(0, RECORDING_PLAYBACK_SAMPLES),
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps, prevState) {
|
||||
if (!prevState.recorder && this.state.recorder) {
|
||||
this.state.recorder.liveData.onUpdate(this.onRecordingUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
public async componentWillUnmount() {
|
||||
await VoiceRecordingStore.instance.disposeRecording();
|
||||
}
|
||||
|
||||
private onRecordingUpdate = (update: IRecordingUpdate): void => {
|
||||
this.waveform = update.waveform;
|
||||
this.seconds = update.timeSeconds;
|
||||
|
||||
if (this.scheduledAnimationFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduledAnimationFrame = true;
|
||||
// The audio recorder flushes data faster than the screen refresh rate
|
||||
// Using requestAnimationFrame makes sure that we only flush the data
|
||||
// to react once per tick to avoid unneeded work.
|
||||
requestAnimationFrame(() => {
|
||||
// The waveform and the downsample target are pretty close, so we should be fine to
|
||||
// do this, despite the docs on arrayFastResample.
|
||||
const bars = arrayFastResample(Array.from(this.waveform), RECORDING_PLAYBACK_SAMPLES);
|
||||
this.setState({
|
||||
// The incoming data is between zero and one, but typically even screaming into a
|
||||
// microphone won't send you over 0.6, so we artificially adjust the gain for the
|
||||
// waveform. This results in a slightly more cinematic/animated waveform for the
|
||||
// user.
|
||||
relHeights: bars.map(b => percentageOf(b, 0, 0.50)),
|
||||
seconds: this.seconds,
|
||||
});
|
||||
this.scheduledAnimationFrame = false;
|
||||
});
|
||||
};
|
||||
|
||||
// called by composer
|
||||
public async send() {
|
||||
if (!this.state.recorder) {
|
||||
|
@ -228,7 +182,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
|||
}
|
||||
|
||||
// only other UI is the recording-in-progress UI
|
||||
return <div className="mx_VoiceMessagePrimaryContainer mx_VoiceRecordComposerTile_recording">
|
||||
return <div className="mx_MediaBody mx_VoiceMessagePrimaryContainer mx_VoiceRecordComposerTile_recording">
|
||||
<LiveRecordingClock recorder={this.state.recorder} />
|
||||
<LiveRecordingWaveform recorder={this.state.recorder} />
|
||||
</div>;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue