mirror of
https://github.com/element-hq/element-web
synced 2024-11-26 03:05:51 +03:00
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:
parent
d81e2cea14
commit
39f2bbaaf4
6 changed files with 116 additions and 33 deletions
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
/>;
|
/>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue