mirror of https://github.com/vector-im/riot-web
Allow voice messages to be scrubbed in the timeline (#8079)
* Use SeekBar for voice messages + move seeking logic to base class * Appease the linter * Update testspull/21833/head
parent
2adc972eec
commit
5fa2ca83ac
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
Copyright 2021 - 2022 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
@ -29,6 +29,7 @@ limitations under the License.
|
||||||
|
|
||||||
contain: content;
|
contain: content;
|
||||||
|
|
||||||
|
// Waveforms are present in live recording only
|
||||||
.mx_Waveform {
|
.mx_Waveform {
|
||||||
.mx_Waveform_bar {
|
.mx_Waveform_bar {
|
||||||
background-color: $quaternary-content;
|
background-color: $quaternary-content;
|
||||||
|
@ -46,11 +47,22 @@ limitations under the License.
|
||||||
|
|
||||||
.mx_Clock {
|
.mx_Clock {
|
||||||
width: $font-42px; // we're not using a monospace font, so fake it
|
width: $font-42px; // we're not using a monospace font, so fake it
|
||||||
|
min-width: $font-42px; // force sensible layouts in awkward flexboxes (file panel, for example)
|
||||||
padding-right: 6px; // with the fixed width this ends up as a visual 8px most of the time, as intended.
|
padding-right: 6px; // with the fixed width this ends up as a visual 8px most of the time, as intended.
|
||||||
padding-left: 8px; // isolate from recording circle / play control
|
padding-left: 8px; // isolate from recording circle / play control
|
||||||
}
|
}
|
||||||
|
|
||||||
&.mx_VoiceMessagePrimaryContainer_noWaveform {
|
// For timeline-rendered playback, mirror the values for where the clock is in
|
||||||
max-width: 162px; // with all the padding this results in 185px wide
|
// the waveform version.
|
||||||
|
.mx_SeekBar {
|
||||||
|
margin-left: 8px;
|
||||||
|
margin-right: 6px;
|
||||||
|
|
||||||
|
& + .mx_Clock {
|
||||||
|
text-align: right;
|
||||||
|
|
||||||
|
// Take the padding off the clock because it's accounted for in the seek bar
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
Copyright 2021 - 2022 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { createRef, ReactNode, RefObject } from "react";
|
import React, { ReactNode } from "react";
|
||||||
|
|
||||||
import PlayPauseButton from "./PlayPauseButton";
|
import PlayPauseButton from "./PlayPauseButton";
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
|
@ -24,41 +24,9 @@ import { _t } from "../../../languageHandler";
|
||||||
import SeekBar from "./SeekBar";
|
import SeekBar from "./SeekBar";
|
||||||
import PlaybackClock from "./PlaybackClock";
|
import PlaybackClock from "./PlaybackClock";
|
||||||
import AudioPlayerBase from "./AudioPlayerBase";
|
import AudioPlayerBase from "./AudioPlayerBase";
|
||||||
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
|
|
||||||
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
|
|
||||||
|
|
||||||
@replaceableComponent("views.audio_messages.AudioPlayer")
|
@replaceableComponent("views.audio_messages.AudioPlayer")
|
||||||
export default class AudioPlayer extends AudioPlayerBase {
|
export default class AudioPlayer extends AudioPlayerBase {
|
||||||
private playPauseRef: RefObject<PlayPauseButton> = createRef();
|
|
||||||
private seekRef: RefObject<SeekBar> = createRef();
|
|
||||||
|
|
||||||
private onKeyDown = (ev: React.KeyboardEvent) => {
|
|
||||||
let handled = true;
|
|
||||||
const action = getKeyBindingsManager().getAccessibilityAction(ev);
|
|
||||||
|
|
||||||
switch (action) {
|
|
||||||
case KeyBindingAction.Space:
|
|
||||||
this.playPauseRef.current?.toggleState();
|
|
||||||
break;
|
|
||||||
case KeyBindingAction.ArrowLeft:
|
|
||||||
this.seekRef.current?.left();
|
|
||||||
break;
|
|
||||||
case KeyBindingAction.ArrowRight:
|
|
||||||
this.seekRef.current?.right();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
handled = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 (handled) {
|
|
||||||
ev.stopPropagation();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
protected renderFileSize(): string {
|
protected renderFileSize(): string {
|
||||||
const bytes = this.props.playback.sizeBytes;
|
const bytes = this.props.playback.sizeBytes;
|
||||||
if (!bytes) return null;
|
if (!bytes) return null;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
Copyright 2021 - 2022 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
@ -14,15 +14,19 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { ReactNode } from "react";
|
import React, { createRef, ReactNode, RefObject } from "react";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
import { Playback, PlaybackState } from "../../../audio/Playback";
|
import { Playback, PlaybackState } from "../../../audio/Playback";
|
||||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
|
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
|
||||||
|
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
|
||||||
|
import SeekBar from "./SeekBar";
|
||||||
|
import PlayPauseButton from "./PlayPauseButton";
|
||||||
|
|
||||||
interface IProps {
|
export interface IProps {
|
||||||
// Playback instance to render. Cannot change during component lifecycle: create
|
// Playback instance to render. Cannot change during component lifecycle: create
|
||||||
// an all-new component instead.
|
// an all-new component instead.
|
||||||
playback: Playback;
|
playback: Playback;
|
||||||
|
@ -36,8 +40,11 @@ interface IState {
|
||||||
}
|
}
|
||||||
|
|
||||||
@replaceableComponent("views.audio_messages.AudioPlayerBase")
|
@replaceableComponent("views.audio_messages.AudioPlayerBase")
|
||||||
export default abstract class AudioPlayerBase extends React.PureComponent<IProps, IState> {
|
export default abstract class AudioPlayerBase<T extends IProps = IProps> extends React.PureComponent<T, IState> {
|
||||||
constructor(props: IProps) {
|
protected seekRef: RefObject<SeekBar> = createRef();
|
||||||
|
protected playPauseRef: RefObject<PlayPauseButton> = createRef();
|
||||||
|
|
||||||
|
constructor(props: T) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
// Playback instances can be reused in the composer
|
// Playback instances can be reused in the composer
|
||||||
|
@ -56,6 +63,33 @@ export default abstract class AudioPlayerBase extends React.PureComponent<IProps
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected onKeyDown = (ev: React.KeyboardEvent) => {
|
||||||
|
let handled = true;
|
||||||
|
const action = getKeyBindingsManager().getAccessibilityAction(ev);
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case KeyBindingAction.Space:
|
||||||
|
this.playPauseRef.current?.toggleState();
|
||||||
|
break;
|
||||||
|
case KeyBindingAction.ArrowLeft:
|
||||||
|
this.seekRef.current?.left();
|
||||||
|
break;
|
||||||
|
case KeyBindingAction.ArrowRight:
|
||||||
|
this.seekRef.current?.right();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
handled = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (handled) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
private onPlaybackUpdate = (ev: PlaybackState) => {
|
private onPlaybackUpdate = (ev: PlaybackState) => {
|
||||||
this.setState({ playbackPhase: ev });
|
this.setState({ playbackPhase: ev });
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
Copyright 2021 - 2022 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
@ -19,29 +19,50 @@ import React, { ReactNode } from "react";
|
||||||
import PlayPauseButton from "./PlayPauseButton";
|
import PlayPauseButton from "./PlayPauseButton";
|
||||||
import PlaybackClock from "./PlaybackClock";
|
import PlaybackClock from "./PlaybackClock";
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
|
import AudioPlayerBase, { IProps as IAudioPlayerBaseProps } from "./AudioPlayerBase";
|
||||||
|
import SeekBar from "./SeekBar";
|
||||||
import PlaybackWaveform from "./PlaybackWaveform";
|
import PlaybackWaveform from "./PlaybackWaveform";
|
||||||
import AudioPlayerBase from "./AudioPlayerBase";
|
|
||||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
interface IProps extends IAudioPlayerBaseProps {
|
||||||
|
/**
|
||||||
|
* When true, use a waveform instead of a seek bar
|
||||||
|
*/
|
||||||
|
withWaveform?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@replaceableComponent("views.audio_messages.RecordingPlayback")
|
@replaceableComponent("views.audio_messages.RecordingPlayback")
|
||||||
export default class RecordingPlayback extends AudioPlayerBase {
|
export default class RecordingPlayback extends AudioPlayerBase<IProps> {
|
||||||
static contextType = RoomContext;
|
// This component is rendered in two ways: the composer and timeline. They have different
|
||||||
public context!: React.ContextType<typeof RoomContext>;
|
// rendering properties (specifically the difference of a waveform or not).
|
||||||
|
|
||||||
private get isWaveformable(): boolean {
|
private renderWaveformLook(): ReactNode {
|
||||||
return this.context.timelineRenderingType !== TimelineRenderingType.Notification
|
return <>
|
||||||
&& this.context.timelineRenderingType !== TimelineRenderingType.File
|
<PlaybackClock playback={this.props.playback} />
|
||||||
&& this.context.timelineRenderingType !== TimelineRenderingType.Pinned;
|
<PlaybackWaveform playback={this.props.playback} />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderSeekableLook(): ReactNode {
|
||||||
|
return <>
|
||||||
|
<SeekBar
|
||||||
|
playback={this.props.playback}
|
||||||
|
tabIndex={-1} // prevent tabbing into the bar
|
||||||
|
playbackPhase={this.state.playbackPhase}
|
||||||
|
ref={this.seekRef}
|
||||||
|
/>
|
||||||
|
<PlaybackClock playback={this.props.playback} />
|
||||||
|
</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected renderComponent(): ReactNode {
|
protected renderComponent(): ReactNode {
|
||||||
const shapeClass = !this.isWaveformable ? 'mx_VoiceMessagePrimaryContainer_noWaveform' : '';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'mx_MediaBody mx_VoiceMessagePrimaryContainer ' + shapeClass}>
|
<div className="mx_MediaBody mx_VoiceMessagePrimaryContainer" onKeyDown={this.onKeyDown}>
|
||||||
<PlayPauseButton playback={this.props.playback} playbackPhase={this.state.playbackPhase} />
|
<PlayPauseButton
|
||||||
<PlaybackClock playback={this.props.playback} />
|
playback={this.props.playback}
|
||||||
{ this.isWaveformable && <PlaybackWaveform playback={this.props.playback} /> }
|
playbackPhase={this.state.playbackPhase}
|
||||||
|
ref={this.playPauseRef}
|
||||||
|
/>
|
||||||
|
{ this.props.withWaveform ? this.renderWaveformLook() : this.renderSeekableLook() }
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -233,7 +233,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
||||||
if (!this.state.recorder) return null; // no recorder means we're not recording: no waveform
|
if (!this.state.recorder) return null; // no recorder means we're not recording: no waveform
|
||||||
|
|
||||||
if (this.state.recordingPhase !== RecordingState.Started) {
|
if (this.state.recordingPhase !== RecordingState.Started) {
|
||||||
return <RecordingPlayback playback={this.state.recorder.getPlayback()} />;
|
return <RecordingPlayback playback={this.state.recorder.getPlayback()} withWaveform={true} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// only other UI is the recording-in-progress UI
|
// only other UI is the recording-in-progress UI
|
||||||
|
|
|
@ -27,6 +27,7 @@ import RoomContext, { TimelineRenderingType } from '../../../../src/contexts/Roo
|
||||||
import { createAudioContext } from '../../../../src/audio/compat';
|
import { createAudioContext } from '../../../../src/audio/compat';
|
||||||
import { findByTestId, flushPromises } from '../../../test-utils';
|
import { findByTestId, flushPromises } from '../../../test-utils';
|
||||||
import PlaybackWaveform from '../../../../src/components/views/audio_messages/PlaybackWaveform';
|
import PlaybackWaveform from '../../../../src/components/views/audio_messages/PlaybackWaveform';
|
||||||
|
import SeekBar from "../../../../src/components/views/audio_messages/SeekBar";
|
||||||
|
|
||||||
jest.mock('../../../../src/audio/compat', () => ({
|
jest.mock('../../../../src/audio/compat', () => ({
|
||||||
createAudioContext: jest.fn(),
|
createAudioContext: jest.fn(),
|
||||||
|
@ -56,7 +57,7 @@ describe('<RecordingPlayback />', () => {
|
||||||
const mockChannelData = new Float32Array();
|
const mockChannelData = new Float32Array();
|
||||||
|
|
||||||
const defaultRoom = { roomId: '!room:server.org', timelineRenderingType: TimelineRenderingType.File };
|
const defaultRoom = { roomId: '!room:server.org', timelineRenderingType: TimelineRenderingType.File };
|
||||||
const getComponent = (props: { playback: Playback }, room = defaultRoom) =>
|
const getComponent = (props: React.ComponentProps<RecordingPlayback>, room = defaultRoom) =>
|
||||||
mount(<RecordingPlayback {...props} />, {
|
mount(<RecordingPlayback {...props} />, {
|
||||||
wrappingComponent: RoomContext.Provider,
|
wrappingComponent: RoomContext.Provider,
|
||||||
wrappingComponentProps: { value: room },
|
wrappingComponentProps: { value: room },
|
||||||
|
@ -128,34 +129,19 @@ describe('<RecordingPlayback />', () => {
|
||||||
expect(playback.toggle).toHaveBeenCalled();
|
expect(playback.toggle).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it('should render a seek bar by default', () => {
|
||||||
[TimelineRenderingType.Notification],
|
|
||||||
[TimelineRenderingType.File],
|
|
||||||
[TimelineRenderingType.Pinned],
|
|
||||||
])('does not render waveform when timeline rendering type for room is %s', (timelineRenderingType) => {
|
|
||||||
const playback = new Playback(new ArrayBuffer(8));
|
const playback = new Playback(new ArrayBuffer(8));
|
||||||
const room = {
|
const component = getComponent({ playback });
|
||||||
...defaultRoom,
|
|
||||||
timelineRenderingType,
|
|
||||||
};
|
|
||||||
const component = getComponent({ playback }, room);
|
|
||||||
|
|
||||||
expect(component.find(PlaybackWaveform).length).toBeFalsy();
|
expect(component.find(PlaybackWaveform).length).toBeFalsy();
|
||||||
|
expect(component.find(SeekBar).length).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it('should render a waveform when requested', () => {
|
||||||
[TimelineRenderingType.Room],
|
|
||||||
[TimelineRenderingType.Thread],
|
|
||||||
[TimelineRenderingType.ThreadsList],
|
|
||||||
[TimelineRenderingType.Search],
|
|
||||||
])('renders waveform when timeline rendering type for room is %s', (timelineRenderingType) => {
|
|
||||||
const playback = new Playback(new ArrayBuffer(8));
|
const playback = new Playback(new ArrayBuffer(8));
|
||||||
const room = {
|
const component = getComponent({ playback, withWaveform: true });
|
||||||
...defaultRoom,
|
|
||||||
timelineRenderingType,
|
|
||||||
};
|
|
||||||
const component = getComponent({ playback }, room);
|
|
||||||
|
|
||||||
expect(component.find(PlaybackWaveform).length).toBeTruthy();
|
expect(component.find(PlaybackWaveform).length).toBeTruthy();
|
||||||
|
expect(component.find(SeekBar).length).toBeFalsy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in New Issue