diff --git a/res/css/views/audio_messages/_PlaybackContainer.scss b/res/css/views/audio_messages/_PlaybackContainer.scss index 408a2953893..4999980beaf 100644 --- a/res/css/views/audio_messages/_PlaybackContainer.scss +++ b/res/css/views/audio_messages/_PlaybackContainer.scss @@ -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"); you may not use this file except in compliance with the License. @@ -29,6 +29,7 @@ limitations under the License. contain: content; + // Waveforms are present in live recording only .mx_Waveform { .mx_Waveform_bar { background-color: $quaternary-content; @@ -46,11 +47,22 @@ limitations under the License. .mx_Clock { 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-left: 8px; // isolate from recording circle / play control } - &.mx_VoiceMessagePrimaryContainer_noWaveform { - max-width: 162px; // with all the padding this results in 185px wide + // For timeline-rendered playback, mirror the values for where the clock is in + // 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; + } } } diff --git a/src/components/views/audio_messages/AudioPlayer.tsx b/src/components/views/audio_messages/AudioPlayer.tsx index 84b96632f10..5f0ccdf450b 100644 --- a/src/components/views/audio_messages/AudioPlayer.tsx +++ b/src/components/views/audio_messages/AudioPlayer.tsx @@ -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"); 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. */ -import React, { createRef, ReactNode, RefObject } from "react"; +import React, { ReactNode } from "react"; import PlayPauseButton from "./PlayPauseButton"; import { replaceableComponent } from "../../../utils/replaceableComponent"; @@ -24,41 +24,9 @@ import { _t } from "../../../languageHandler"; import SeekBar from "./SeekBar"; import PlaybackClock from "./PlaybackClock"; import AudioPlayerBase from "./AudioPlayerBase"; -import { getKeyBindingsManager } from "../../../KeyBindingsManager"; -import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; @replaceableComponent("views.audio_messages.AudioPlayer") export default class AudioPlayer extends AudioPlayerBase { - private playPauseRef: RefObject = createRef(); - private seekRef: RefObject = 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 { const bytes = this.props.playback.sizeBytes; if (!bytes) return null; diff --git a/src/components/views/audio_messages/AudioPlayerBase.tsx b/src/components/views/audio_messages/AudioPlayerBase.tsx index 1ce0ed1e955..f3643508592 100644 --- a/src/components/views/audio_messages/AudioPlayerBase.tsx +++ b/src/components/views/audio_messages/AudioPlayerBase.tsx @@ -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"); 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. */ -import React, { ReactNode } from "react"; +import React, { createRef, ReactNode, RefObject } from "react"; import { logger } from "matrix-js-sdk/src/logger"; import { Playback, PlaybackState } from "../../../audio/Playback"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { replaceableComponent } from "../../../utils/replaceableComponent"; 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 // an all-new component instead. playback: Playback; @@ -36,8 +40,11 @@ interface IState { } @replaceableComponent("views.audio_messages.AudioPlayerBase") -export default abstract class AudioPlayerBase extends React.PureComponent { - constructor(props: IProps) { +export default abstract class AudioPlayerBase extends React.PureComponent { + protected seekRef: RefObject = createRef(); + protected playPauseRef: RefObject = createRef(); + + constructor(props: T) { super(props); // Playback instances can be reused in the composer @@ -56,6 +63,33 @@ export default abstract class AudioPlayerBase extends React.PureComponent { + 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) => { this.setState({ playbackPhase: ev }); }; diff --git a/src/components/views/audio_messages/RecordingPlayback.tsx b/src/components/views/audio_messages/RecordingPlayback.tsx index 05fca276fe9..700231faac8 100644 --- a/src/components/views/audio_messages/RecordingPlayback.tsx +++ b/src/components/views/audio_messages/RecordingPlayback.tsx @@ -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"); 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 PlaybackClock from "./PlaybackClock"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import AudioPlayerBase, { IProps as IAudioPlayerBaseProps } from "./AudioPlayerBase"; +import SeekBar from "./SeekBar"; 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") -export default class RecordingPlayback extends AudioPlayerBase { - static contextType = RoomContext; - public context!: React.ContextType; - - private get isWaveformable(): boolean { - return this.context.timelineRenderingType !== TimelineRenderingType.Notification - && this.context.timelineRenderingType !== TimelineRenderingType.File - && this.context.timelineRenderingType !== TimelineRenderingType.Pinned; +export default class RecordingPlayback extends AudioPlayerBase { + // This component is rendered in two ways: the composer and timeline. They have different + // rendering properties (specifically the difference of a waveform or not). + + private renderWaveformLook(): ReactNode { + return <> + + + ; } - protected renderComponent(): ReactNode { - const shapeClass = !this.isWaveformable ? 'mx_VoiceMessagePrimaryContainer_noWaveform' : ''; + private renderSeekableLook(): ReactNode { + return <> + + + ; + } + protected renderComponent(): ReactNode { return ( -
- - - { this.isWaveformable && } +
+ + { this.props.withWaveform ? this.renderWaveformLook() : this.renderSeekableLook() }
); } diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx index 448883645e2..bed665f8b35 100644 --- a/src/components/views/rooms/VoiceRecordComposerTile.tsx +++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -233,7 +233,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent; + return ; } // only other UI is the recording-in-progress UI diff --git a/test/components/views/audio_messages/RecordingPlayback-test.tsx b/test/components/views/audio_messages/RecordingPlayback-test.tsx index f8a6c0ef922..4582e7b197a 100644 --- a/test/components/views/audio_messages/RecordingPlayback-test.tsx +++ b/test/components/views/audio_messages/RecordingPlayback-test.tsx @@ -27,6 +27,7 @@ import RoomContext, { TimelineRenderingType } from '../../../../src/contexts/Roo import { createAudioContext } from '../../../../src/audio/compat'; import { findByTestId, flushPromises } from '../../../test-utils'; import PlaybackWaveform from '../../../../src/components/views/audio_messages/PlaybackWaveform'; +import SeekBar from "../../../../src/components/views/audio_messages/SeekBar"; jest.mock('../../../../src/audio/compat', () => ({ createAudioContext: jest.fn(), @@ -56,7 +57,7 @@ describe('', () => { const mockChannelData = new Float32Array(); const defaultRoom = { roomId: '!room:server.org', timelineRenderingType: TimelineRenderingType.File }; - const getComponent = (props: { playback: Playback }, room = defaultRoom) => + const getComponent = (props: React.ComponentProps, room = defaultRoom) => mount(, { wrappingComponent: RoomContext.Provider, wrappingComponentProps: { value: room }, @@ -128,34 +129,19 @@ describe('', () => { expect(playback.toggle).toHaveBeenCalled(); }); - it.each([ - [TimelineRenderingType.Notification], - [TimelineRenderingType.File], - [TimelineRenderingType.Pinned], - ])('does not render waveform when timeline rendering type for room is %s', (timelineRenderingType) => { + it('should render a seek bar by default', () => { const playback = new Playback(new ArrayBuffer(8)); - const room = { - ...defaultRoom, - timelineRenderingType, - }; - const component = getComponent({ playback }, room); + const component = getComponent({ playback }); expect(component.find(PlaybackWaveform).length).toBeFalsy(); + expect(component.find(SeekBar).length).toBeTruthy(); }); - it.each([ - [TimelineRenderingType.Room], - [TimelineRenderingType.Thread], - [TimelineRenderingType.ThreadsList], - [TimelineRenderingType.Search], - ])('renders waveform when timeline rendering type for room is %s', (timelineRenderingType) => { + it('should render a waveform when requested', () => { const playback = new Playback(new ArrayBuffer(8)); - const room = { - ...defaultRoom, - timelineRenderingType, - }; - const component = getComponent({ playback }, room); + const component = getComponent({ playback, withWaveform: true }); expect(component.find(PlaybackWaveform).length).toBeTruthy(); + expect(component.find(SeekBar).length).toBeFalsy(); }); });