Bring back waveform for voice messages and retain seeking (#8843)

* Crude way of layering the waveform and seek bar

Not intended for production.

* Use a layout prop instead of something less descriptive

* Fix alignment properly, and play with styles

* Convert back to a ball

* Use `transparent` which makes NVDA happy enough

* Allow keyboards in the seek bar

* Try to make the clock behave more correctly with screen readers

MIDNIGHT

* Remove legacy export

* Remove redundant attr

* Appease the linter
This commit is contained in:
Travis Ralston 2022-06-14 18:13:13 -06:00 committed by GitHub
parent d81e2cea14
commit 39f2bbaaf4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 116 additions and 33 deletions

View file

@ -52,16 +52,57 @@ limitations under the License.
padding-left: 8px; // isolate from recording circle / play control padding-left: 8px; // isolate from recording circle / play control
} }
// For timeline-rendered playback, mirror the values for where the clock is in .mx_RecordingPlayback_timelineLayoutMiddle {
// the waveform version.
.mx_SeekBar {
margin-left: 8px; margin-left: 8px;
margin-right: 6px; margin-right: 6px;
position: relative;
display: inline-block;
flex: 1;
height: 30px; // same height as mx_Waveform, needed for automatic vertical centering
.mx_Waveform {
position: absolute;
left: 0;
top: 0;
}
.mx_SeekBar {
position: absolute;
left: 0;
height: 30px;
top: -2px; // visually vertically centered
// Hide the hairline progress bar since we're at 100% height. Need to have distinct rules
// because CSS is weird.
background: none;
&::before {
background: none;
}
&::-moz-range-progress {
background: none;
}
// Make the thumb easier to see. Like the SeekBar original styles, these need to be
// distinct. We make it transparent so it doesn't show up on the UI, but also larger
// so it's easier to grab by mouse users in some browsers. Most browsers let the user
// move and drag the thumb regardless of hitting the thumb, however.
&::-webkit-slider-thumb {
width: 10px;
height: 10px;
background-color: transparent;
}
&::-moz-range-thumb {
width: 10px;
height: 10px;
background-color: transparent;
}
}
// For timeline-rendered playback, the clock is on the other side of the waveform.
& + .mx_Clock { & + .mx_Clock {
text-align: right; text-align: right;
// Take the padding off the clock because it's accounted for in the seek bar // Take the padding off the clock because it's accounted for by the `timelineLayoutMiddle`
padding: 0; padding: 0;
} }
} }

View file

@ -18,7 +18,7 @@ import React, { HTMLProps } from "react";
import { formatSeconds } from "../../../DateUtils"; import { formatSeconds } from "../../../DateUtils";
export interface IProps extends Pick<HTMLProps<HTMLSpanElement>, "aria-live"> { interface IProps extends Pick<HTMLProps<HTMLSpanElement>, "aria-live" | "role"> {
seconds: number; seconds: number;
} }
@ -31,14 +31,14 @@ export default class Clock extends React.Component<IProps> {
super(props); super(props);
} }
shouldComponentUpdate(nextProps: Readonly<IProps>): boolean { public shouldComponentUpdate(nextProps: Readonly<IProps>): boolean {
const currentFloor = Math.floor(this.props.seconds); const currentFloor = Math.floor(this.props.seconds);
const nextFloor = Math.floor(nextProps.seconds); const nextFloor = Math.floor(nextProps.seconds);
return currentFloor !== nextFloor; return currentFloor !== nextFloor;
} }
public render() { public render() {
return <span aria-live={this.props["aria-live"]} className='mx_Clock'> return <span aria-live={this.props["aria-live"]} role={this.props.role} className='mx_Clock'>
{ formatSeconds(this.props.seconds) } { formatSeconds(this.props.seconds) }
</span>; </span>;
} }

View file

@ -76,7 +76,7 @@ export default class PlaybackClock extends React.PureComponent<IProps, IState> {
} }
return <Clock return <Clock
seconds={seconds} seconds={seconds}
aria-live={this.state.playbackPhase === PlaybackState.Playing ? "off" : undefined} role="timer"
/>; />;
} }
} }

View file

@ -22,37 +22,60 @@ import AudioPlayerBase, { IProps as IAudioPlayerBaseProps } from "./AudioPlayerB
import SeekBar from "./SeekBar"; import SeekBar from "./SeekBar";
import PlaybackWaveform from "./PlaybackWaveform"; import PlaybackWaveform from "./PlaybackWaveform";
interface IProps extends IAudioPlayerBaseProps { export enum PlaybackLayout {
/** /**
* When true, use a waveform instead of a seek bar * Clock on the left side of a waveform, without seek bar.
*/ */
withWaveform?: boolean; Composer,
/**
* Clock on the right side of a waveform, with an added seek bar.
*/
Timeline,
}
interface IProps extends IAudioPlayerBaseProps {
layout?: PlaybackLayout; // Defaults to Timeline layout
} }
export default class RecordingPlayback extends AudioPlayerBase<IProps> { export default class RecordingPlayback extends AudioPlayerBase<IProps> {
// This component is rendered in two ways: the composer and timeline. They have different // This component is rendered in two ways: the composer and timeline. They have different
// rendering properties (specifically the difference of a waveform or not). // rendering properties (specifically the difference of a waveform or not).
private renderWaveformLook(): ReactNode { private renderComposerLook(): ReactNode {
return <> return <>
<PlaybackClock playback={this.props.playback} /> <PlaybackClock playback={this.props.playback} />
<PlaybackWaveform playback={this.props.playback} /> <PlaybackWaveform playback={this.props.playback} />
</>; </>;
} }
private renderSeekableLook(): ReactNode { private renderTimelineLook(): ReactNode {
return <> return <>
<SeekBar <div className="mx_RecordingPlayback_timelineLayoutMiddle">
playback={this.props.playback} <PlaybackWaveform playback={this.props.playback} />
tabIndex={-1} // prevent tabbing into the bar <SeekBar
playbackPhase={this.state.playbackPhase} playback={this.props.playback}
ref={this.seekRef} tabIndex={0} // allow keyboard users to fall into the seek bar
/> playbackPhase={this.state.playbackPhase}
ref={this.seekRef}
/>
</div>
<PlaybackClock playback={this.props.playback} /> <PlaybackClock playback={this.props.playback} />
</>; </>;
} }
protected renderComponent(): ReactNode { protected renderComponent(): ReactNode {
let body: ReactNode;
switch (this.props.layout) {
case PlaybackLayout.Composer:
body = this.renderComposerLook();
break;
case PlaybackLayout.Timeline: // default is timeline, fall through.
default:
body = this.renderTimelineLook();
break;
}
return ( return (
<div className="mx_MediaBody mx_VoiceMessagePrimaryContainer" onKeyDown={this.onKeyDown}> <div className="mx_MediaBody mx_VoiceMessagePrimaryContainer" onKeyDown={this.onKeyDown}>
<PlayPauseButton <PlayPauseButton
@ -60,7 +83,7 @@ export default class RecordingPlayback extends AudioPlayerBase<IProps> {
playbackPhase={this.state.playbackPhase} playbackPhase={this.state.playbackPhase}
ref={this.playPauseRef} ref={this.playPauseRef}
/> />
{ this.props.withWaveform ? this.renderWaveformLook() : this.renderSeekableLook() } { body }
</div> </div>
); );
} }

View file

@ -28,7 +28,7 @@ import LiveRecordingWaveform from "../audio_messages/LiveRecordingWaveform";
import LiveRecordingClock from "../audio_messages/LiveRecordingClock"; import LiveRecordingClock from "../audio_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 RecordingPlayback from "../audio_messages/RecordingPlayback"; import RecordingPlayback, { PlaybackLayout } from "../audio_messages/RecordingPlayback";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog"; import ErrorDialog from "../dialogs/ErrorDialog";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler"; import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler";
@ -231,7 +231,10 @@ 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()} withWaveform={true} />; return <RecordingPlayback
playback={this.state.recorder.getPlayback()}
layout={PlaybackLayout.Composer}
/>;
} }
// only other UI is the recording-in-progress UI // only other UI is the recording-in-progress UI

View file

@ -20,13 +20,14 @@ import { mocked } from 'jest-mock';
import { logger } from 'matrix-js-sdk/src/logger'; import { logger } from 'matrix-js-sdk/src/logger';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import RecordingPlayback from '../../../../src/components/views/audio_messages/RecordingPlayback'; import RecordingPlayback, { PlaybackLayout } from '../../../../src/components/views/audio_messages/RecordingPlayback';
import { Playback } from '../../../../src/audio/Playback'; import { Playback } from '../../../../src/audio/Playback';
import RoomContext, { TimelineRenderingType } from '../../../../src/contexts/RoomContext'; import RoomContext, { TimelineRenderingType } from '../../../../src/contexts/RoomContext';
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"; import SeekBar from "../../../../src/components/views/audio_messages/SeekBar";
import PlaybackClock from "../../../../src/components/views/audio_messages/PlaybackClock";
jest.mock('../../../../src/audio/compat', () => ({ jest.mock('../../../../src/audio/compat', () => ({
createAudioContext: jest.fn(), createAudioContext: jest.fn(),
@ -128,19 +129,34 @@ describe('<RecordingPlayback />', () => {
expect(playback.toggle).toHaveBeenCalled(); expect(playback.toggle).toHaveBeenCalled();
}); });
it('should render a seek bar by default', () => { describe('Composer Layout', () => {
const playback = new Playback(new ArrayBuffer(8)); it('should have a waveform, no seek bar, and clock', () => {
const component = getComponent({ playback }); const playback = new Playback(new ArrayBuffer(8));
const component = getComponent({ playback, layout: PlaybackLayout.Composer });
expect(component.find(PlaybackWaveform).length).toBeFalsy(); expect(component.find(PlaybackClock).length).toBeTruthy();
expect(component.find(SeekBar).length).toBeTruthy(); expect(component.find(PlaybackWaveform).length).toBeTruthy();
expect(component.find(SeekBar).length).toBeFalsy();
});
}); });
it('should render a waveform when requested', () => { describe('Timeline Layout', () => {
const playback = new Playback(new ArrayBuffer(8)); it('should have a waveform, a seek bar, and clock', () => {
const component = getComponent({ playback, withWaveform: true }); const playback = new Playback(new ArrayBuffer(8));
const component = getComponent({ playback, layout: PlaybackLayout.Timeline });
expect(component.find(PlaybackWaveform).length).toBeTruthy(); expect(component.find(PlaybackClock).length).toBeTruthy();
expect(component.find(SeekBar).length).toBeFalsy(); expect(component.find(PlaybackWaveform).length).toBeTruthy();
expect(component.find(SeekBar).length).toBeTruthy();
});
it('should be the default', () => {
const playback = new Playback(new ArrayBuffer(8));
const component = getComponent({ playback }); // no layout set for test
expect(component.find(PlaybackClock).length).toBeTruthy();
expect(component.find(PlaybackWaveform).length).toBeTruthy();
expect(component.find(SeekBar).length).toBeTruthy();
});
}); });
}); });