diff --git a/src/audio/Playback.ts b/src/audio/Playback.ts index a25794b59e..e2152aa848 100644 --- a/src/audio/Playback.ts +++ b/src/audio/Playback.ts @@ -32,6 +32,13 @@ export enum PlaybackState { Playing = "playing", // active progress through timeline } +export interface PlaybackInterface { + readonly liveData: SimpleObservable; + readonly timeSeconds: number; + readonly durationSeconds: number; + skipTo(timeSeconds: number): Promise; +} + export const PLAYBACK_WAVEFORM_SAMPLES = 39; const THUMBNAIL_WAVEFORM_SAMPLES = 100; // arbitrary: [30,120] export const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES); @@ -45,7 +52,7 @@ function makePlaybackWaveform(input: number[]): number[] { return arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1); } -export class Playback extends EventEmitter implements IDestroyable { +export class Playback extends EventEmitter implements IDestroyable, PlaybackInterface { /** * Stable waveform for representing a thumbnail of the media. Values are * guaranteed to be between zero and one, inclusive. @@ -111,6 +118,18 @@ export class Playback extends EventEmitter implements IDestroyable { return this.currentState === PlaybackState.Playing; } + public get liveData(): SimpleObservable { + return this.clock.liveData; + } + + public get timeSeconds(): number { + return this.clock.timeSeconds; + } + + public get durationSeconds(): number { + return this.clock.durationSeconds; + } + public emit(event: PlaybackState, ...args: any[]): boolean { this.state = event; super.emit(event, ...args); diff --git a/src/components/views/audio_messages/AudioPlayer.tsx b/src/components/views/audio_messages/AudioPlayer.tsx index c681b366cf..60bd7fc7fc 100644 --- a/src/components/views/audio_messages/AudioPlayer.tsx +++ b/src/components/views/audio_messages/AudioPlayer.tsx @@ -23,6 +23,7 @@ import { _t } from "../../../languageHandler"; import SeekBar from "./SeekBar"; import PlaybackClock from "./PlaybackClock"; import AudioPlayerBase from "./AudioPlayerBase"; +import { PlaybackState } from "../../../audio/Playback"; export default class AudioPlayer extends AudioPlayerBase { protected renderFileSize(): string { @@ -61,7 +62,7 @@ export default class AudioPlayer extends AudioPlayerBase { diff --git a/src/components/views/audio_messages/RecordingPlayback.tsx b/src/components/views/audio_messages/RecordingPlayback.tsx index a65fc6e6a8..9902b15d68 100644 --- a/src/components/views/audio_messages/RecordingPlayback.tsx +++ b/src/components/views/audio_messages/RecordingPlayback.tsx @@ -21,6 +21,7 @@ import PlaybackClock from "./PlaybackClock"; import AudioPlayerBase, { IProps as IAudioPlayerBaseProps } from "./AudioPlayerBase"; import SeekBar from "./SeekBar"; import PlaybackWaveform from "./PlaybackWaveform"; +import { PlaybackState } from "../../../audio/Playback"; export enum PlaybackLayout { /** @@ -56,7 +57,7 @@ export default class RecordingPlayback extends AudioPlayerBase { diff --git a/src/components/views/audio_messages/SeekBar.tsx b/src/components/views/audio_messages/SeekBar.tsx index d86d11c95e..5e2d90e906 100644 --- a/src/components/views/audio_messages/SeekBar.tsx +++ b/src/components/views/audio_messages/SeekBar.tsx @@ -16,20 +16,20 @@ limitations under the License. import React, { ChangeEvent, CSSProperties, ReactNode } from "react"; -import { Playback, PlaybackState } from "../../../audio/Playback"; +import { PlaybackInterface } from "../../../audio/Playback"; 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; + playback: PlaybackInterface; // Tab index for the underlying component. Useful if the seek bar is in a managed state. // Defaults to zero. tabIndex?: number; - playbackPhase: PlaybackState; + disabled?: boolean; } interface IState { @@ -52,6 +52,7 @@ export default class SeekBar extends React.PureComponent { public static defaultProps = { tabIndex: 0, + disabled: false, }; constructor(props: IProps) { @@ -62,26 +63,26 @@ export default class SeekBar extends React.PureComponent { }; // We don't need to de-register: the class handles this for us internally - this.props.playback.clockInfo.liveData.onUpdate(() => this.animationFrameFn.mark()); + this.props.playback.liveData.onUpdate(() => this.animationFrameFn.mark()); } private doUpdate() { this.setState({ percentage: percentageOf( - this.props.playback.clockInfo.timeSeconds, + this.props.playback.timeSeconds, 0, - this.props.playback.clockInfo.durationSeconds), + this.props.playback.durationSeconds), }); } public left() { // noinspection JSIgnoredPromiseFromCall - this.props.playback.skipTo(this.props.playback.clockInfo.timeSeconds - ARROW_SKIP_SECONDS); + this.props.playback.skipTo(this.props.playback.timeSeconds - ARROW_SKIP_SECONDS); } public right() { // noinspection JSIgnoredPromiseFromCall - this.props.playback.skipTo(this.props.playback.clockInfo.timeSeconds + ARROW_SKIP_SECONDS); + this.props.playback.skipTo(this.props.playback.timeSeconds + ARROW_SKIP_SECONDS); } private onChange = (ev: ChangeEvent) => { @@ -89,7 +90,7 @@ export default class SeekBar extends React.PureComponent { // 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); + this.props.playback.skipTo(Number(ev.target.value) * this.props.playback.durationSeconds); }; public render(): ReactNode { @@ -105,7 +106,7 @@ export default class SeekBar extends React.PureComponent { value={this.state.percentage} step={0.001} style={{ '--fillTo': this.state.percentage } as ISeekCSS} - disabled={this.props.playbackPhase === PlaybackState.Decoding} + disabled={this.props.disabled} />; } } diff --git a/test/audio/Playback-test.ts b/test/audio/Playback-test.ts index a1637d75be..319b90d59e 100644 --- a/test/audio/Playback-test.ts +++ b/test/audio/Playback-test.ts @@ -36,6 +36,7 @@ describe('Playback', () => { suspend: jest.fn(), resume: jest.fn(), createBufferSource: jest.fn().mockReturnValue(mockAudioBufferSourceNode), + currentTime: 1337, }; const mockAudioBuffer = { @@ -61,9 +62,12 @@ describe('Playback', () => { const buffer = new ArrayBuffer(8); const playback = new Playback(buffer); + playback.clockInfo.durationSeconds = mockAudioBuffer.duration; expect(playback.sizeBytes).toEqual(8); expect(playback.clockInfo).toBeTruthy(); + expect(playback.liveData).toBe(playback.clockInfo.liveData); + expect(playback.timeSeconds).toBe(1337 % 99); expect(playback.currentState).toEqual(PlaybackState.Decoding); }); @@ -118,6 +122,7 @@ describe('Playback', () => { // clock was updated expect(playback.clockInfo.durationSeconds).toEqual(mockAudioBuffer.duration); + expect(playback.durationSeconds).toEqual(mockAudioBuffer.duration); expect(playback.currentState).toEqual(PlaybackState.Stopped); }); @@ -144,6 +149,7 @@ describe('Playback', () => { // clock was updated expect(playback.clockInfo.durationSeconds).toEqual(mockAudioBuffer.duration); + expect(playback.durationSeconds).toEqual(mockAudioBuffer.duration); expect(playback.currentState).toEqual(PlaybackState.Stopped); }); diff --git a/test/components/views/audio_messages/SeekBar-test.tsx b/test/components/views/audio_messages/SeekBar-test.tsx new file mode 100644 index 0000000000..718c9a8469 --- /dev/null +++ b/test/components/views/audio_messages/SeekBar-test.tsx @@ -0,0 +1,106 @@ +/* +Copyright 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. +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, { createRef, RefObject } from "react"; +import { mocked } from "jest-mock"; +import { act, fireEvent, render, RenderResult } from "@testing-library/react"; + +import { Playback } from "../../../../src/audio/Playback"; +import { createTestPlayback } from "../../../test-utils/audio"; +import SeekBar from "../../../../src/components/views/audio_messages/SeekBar"; + +describe("SeekBar", () => { + let playback: Playback; + let renderResult: RenderResult; + let frameRequestCallback: FrameRequestCallback; + let seekBarRef: RefObject; + + beforeEach(() => { + seekBarRef = createRef(); + jest.spyOn(window, "requestAnimationFrame").mockImplementation( + (callback: FrameRequestCallback) => { frameRequestCallback = callback; return 0; }, + ); + playback = createTestPlayback(); + }); + + afterEach(() => { + mocked(window.requestAnimationFrame).mockRestore(); + }); + + describe("when rendering a SeekBar", () => { + beforeEach(async () => { + renderResult = render(); + act(() => { + playback.liveData.update([playback.timeSeconds, playback.durationSeconds]); + frameRequestCallback(0); + }); + }); + + it("should render as expected", () => { + // expected value 3141 / 31415 ~ 0.099984084 + expect(renderResult.container).toMatchSnapshot(); + }); + + describe("and seeking position with the slider", () => { + beforeEach(() => { + const rangeInput = renderResult.container.querySelector("[type='range']"); + act(() => { + fireEvent.change(rangeInput, { target: { value: 0.5 } }); + }); + }); + + it("should update the playback", () => { + expect(playback.skipTo).toHaveBeenCalledWith(0.5 * playback.durationSeconds); + }); + + describe("and seeking left", () => { + beforeEach(() => { + mocked(playback.skipTo).mockClear(); + act(() => { + seekBarRef.current.left(); + }); + }); + + it("should skip to minus 5 seconds", () => { + expect(playback.skipTo).toHaveBeenCalledWith(playback.timeSeconds - 5); + }); + }); + + describe("and seeking right", () => { + beforeEach(() => { + mocked(playback.skipTo).mockClear(); + act(() => { + seekBarRef.current.right(); + }); + }); + + it("should skip to plus 5 seconds", () => { + expect(playback.skipTo).toHaveBeenCalledWith(playback.timeSeconds + 5); + }); + }); + }); + }); + + describe("when rendering a disabled SeekBar", () => { + beforeEach(async () => { + renderResult = render(); + }); + + it("should render as expected", () => { + expect(renderResult.container).toMatchSnapshot(); + }); + }); +}); diff --git a/test/components/views/audio_messages/__snapshots__/SeekBar-test.tsx.snap b/test/components/views/audio_messages/__snapshots__/SeekBar-test.tsx.snap new file mode 100644 index 0000000000..f545f94d74 --- /dev/null +++ b/test/components/views/audio_messages/__snapshots__/SeekBar-test.tsx.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SeekBar when rendering a SeekBar should render as expected 1`] = ` +
+ +
+`; + +exports[`SeekBar when rendering a disabled SeekBar should render as expected 1`] = ` +
+ +
+`; diff --git a/test/test-utils/audio.ts b/test/test-utils/audio.ts index 012e353a98..8996d97b53 100644 --- a/test/test-utils/audio.ts +++ b/test/test-utils/audio.ts @@ -63,6 +63,9 @@ export const createTestPlayback = (): Playback => { eventNames: eventEmitter.eventNames.bind(eventEmitter), prependListener: eventEmitter.prependListener.bind(eventEmitter), prependOnceListener: eventEmitter.prependOnceListener.bind(eventEmitter), + liveData: new SimpleObservable(), + durationSeconds: 31415, + timeSeconds: 3141, } as PublicInterface as Playback; };