Extract PlaybackInterface (#9526)

This commit is contained in:
Michael Weimann 2022-11-02 09:46:42 +01:00 committed by GitHub
parent 1e65dcd0aa
commit 9096bd82d6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 182 additions and 13 deletions

View file

@ -32,6 +32,13 @@ export enum PlaybackState {
Playing = "playing", // active progress through timeline Playing = "playing", // active progress through timeline
} }
export interface PlaybackInterface {
readonly liveData: SimpleObservable<number[]>;
readonly timeSeconds: number;
readonly durationSeconds: number;
skipTo(timeSeconds: number): Promise<void>;
}
export const PLAYBACK_WAVEFORM_SAMPLES = 39; export const PLAYBACK_WAVEFORM_SAMPLES = 39;
const THUMBNAIL_WAVEFORM_SAMPLES = 100; // arbitrary: [30,120] const THUMBNAIL_WAVEFORM_SAMPLES = 100; // arbitrary: [30,120]
export const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES); 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); 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 * Stable waveform for representing a thumbnail of the media. Values are
* guaranteed to be between zero and one, inclusive. * guaranteed to be between zero and one, inclusive.
@ -111,6 +118,18 @@ export class Playback extends EventEmitter implements IDestroyable {
return this.currentState === PlaybackState.Playing; return this.currentState === PlaybackState.Playing;
} }
public get liveData(): SimpleObservable<number[]> {
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 { public emit(event: PlaybackState, ...args: any[]): boolean {
this.state = event; this.state = event;
super.emit(event, ...args); super.emit(event, ...args);

View file

@ -23,6 +23,7 @@ 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 { PlaybackState } from "../../../audio/Playback";
export default class AudioPlayer extends AudioPlayerBase { export default class AudioPlayer extends AudioPlayerBase {
protected renderFileSize(): string { protected renderFileSize(): string {
@ -61,7 +62,7 @@ export default class AudioPlayer extends AudioPlayerBase {
<SeekBar <SeekBar
playback={this.props.playback} playback={this.props.playback}
tabIndex={-1} // prevent tabbing into the bar tabIndex={-1} // prevent tabbing into the bar
playbackPhase={this.state.playbackPhase} disabled={this.state.playbackPhase === PlaybackState.Decoding}
ref={this.seekRef} ref={this.seekRef}
/> />
<PlaybackClock playback={this.props.playback} defaultDisplaySeconds={0} /> <PlaybackClock playback={this.props.playback} defaultDisplaySeconds={0} />

View file

@ -21,6 +21,7 @@ import PlaybackClock from "./PlaybackClock";
import AudioPlayerBase, { IProps as IAudioPlayerBaseProps } from "./AudioPlayerBase"; import AudioPlayerBase, { IProps as IAudioPlayerBaseProps } from "./AudioPlayerBase";
import SeekBar from "./SeekBar"; import SeekBar from "./SeekBar";
import PlaybackWaveform from "./PlaybackWaveform"; import PlaybackWaveform from "./PlaybackWaveform";
import { PlaybackState } from "../../../audio/Playback";
export enum PlaybackLayout { export enum PlaybackLayout {
/** /**
@ -56,7 +57,7 @@ export default class RecordingPlayback extends AudioPlayerBase<IProps> {
<SeekBar <SeekBar
playback={this.props.playback} playback={this.props.playback}
tabIndex={0} // allow keyboard users to fall into the seek bar tabIndex={0} // allow keyboard users to fall into the seek bar
playbackPhase={this.state.playbackPhase} disabled={this.state.playbackPhase === PlaybackState.Decoding}
ref={this.seekRef} ref={this.seekRef}
/> />
</div> </div>

View file

@ -16,20 +16,20 @@ limitations under the License.
import React, { ChangeEvent, CSSProperties, ReactNode } from "react"; import React, { ChangeEvent, CSSProperties, ReactNode } from "react";
import { Playback, PlaybackState } from "../../../audio/Playback"; import { PlaybackInterface } from "../../../audio/Playback";
import { MarkedExecution } from "../../../utils/MarkedExecution"; import { MarkedExecution } from "../../../utils/MarkedExecution";
import { percentageOf } from "../../../utils/numbers"; import { percentageOf } from "../../../utils/numbers";
interface IProps { 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: PlaybackInterface;
// Tab index for the underlying component. Useful if the seek bar is in a managed state. // Tab index for the underlying component. Useful if the seek bar is in a managed state.
// Defaults to zero. // Defaults to zero.
tabIndex?: number; tabIndex?: number;
playbackPhase: PlaybackState; disabled?: boolean;
} }
interface IState { interface IState {
@ -52,6 +52,7 @@ export default class SeekBar extends React.PureComponent<IProps, IState> {
public static defaultProps = { public static defaultProps = {
tabIndex: 0, tabIndex: 0,
disabled: false,
}; };
constructor(props: IProps) { constructor(props: IProps) {
@ -62,26 +63,26 @@ export default class SeekBar extends React.PureComponent<IProps, IState> {
}; };
// We don't need to de-register: the class handles this for us internally // 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() { private doUpdate() {
this.setState({ this.setState({
percentage: percentageOf( percentage: percentageOf(
this.props.playback.clockInfo.timeSeconds, this.props.playback.timeSeconds,
0, 0,
this.props.playback.clockInfo.durationSeconds), this.props.playback.durationSeconds),
}); });
} }
public left() { public left() {
// noinspection JSIgnoredPromiseFromCall // 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() { public right() {
// noinspection JSIgnoredPromiseFromCall // 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<HTMLInputElement>) => { private onChange = (ev: ChangeEvent<HTMLInputElement>) => {
@ -89,7 +90,7 @@ export default class SeekBar extends React.PureComponent<IProps, IState> {
// change the value on the component. We can use this as a reliable "skip to X" function. // change the value on the component. We can use this as a reliable "skip to X" function.
// //
// noinspection JSIgnoredPromiseFromCall // 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 { public render(): ReactNode {
@ -105,7 +106,7 @@ export default class SeekBar extends React.PureComponent<IProps, IState> {
value={this.state.percentage} value={this.state.percentage}
step={0.001} step={0.001}
style={{ '--fillTo': this.state.percentage } as ISeekCSS} style={{ '--fillTo': this.state.percentage } as ISeekCSS}
disabled={this.props.playbackPhase === PlaybackState.Decoding} disabled={this.props.disabled}
/>; />;
} }
} }

View file

@ -36,6 +36,7 @@ describe('Playback', () => {
suspend: jest.fn(), suspend: jest.fn(),
resume: jest.fn(), resume: jest.fn(),
createBufferSource: jest.fn().mockReturnValue(mockAudioBufferSourceNode), createBufferSource: jest.fn().mockReturnValue(mockAudioBufferSourceNode),
currentTime: 1337,
}; };
const mockAudioBuffer = { const mockAudioBuffer = {
@ -61,9 +62,12 @@ describe('Playback', () => {
const buffer = new ArrayBuffer(8); const buffer = new ArrayBuffer(8);
const playback = new Playback(buffer); const playback = new Playback(buffer);
playback.clockInfo.durationSeconds = mockAudioBuffer.duration;
expect(playback.sizeBytes).toEqual(8); expect(playback.sizeBytes).toEqual(8);
expect(playback.clockInfo).toBeTruthy(); expect(playback.clockInfo).toBeTruthy();
expect(playback.liveData).toBe(playback.clockInfo.liveData);
expect(playback.timeSeconds).toBe(1337 % 99);
expect(playback.currentState).toEqual(PlaybackState.Decoding); expect(playback.currentState).toEqual(PlaybackState.Decoding);
}); });
@ -118,6 +122,7 @@ describe('Playback', () => {
// clock was updated // clock was updated
expect(playback.clockInfo.durationSeconds).toEqual(mockAudioBuffer.duration); expect(playback.clockInfo.durationSeconds).toEqual(mockAudioBuffer.duration);
expect(playback.durationSeconds).toEqual(mockAudioBuffer.duration);
expect(playback.currentState).toEqual(PlaybackState.Stopped); expect(playback.currentState).toEqual(PlaybackState.Stopped);
}); });
@ -144,6 +149,7 @@ describe('Playback', () => {
// clock was updated // clock was updated
expect(playback.clockInfo.durationSeconds).toEqual(mockAudioBuffer.duration); expect(playback.clockInfo.durationSeconds).toEqual(mockAudioBuffer.duration);
expect(playback.durationSeconds).toEqual(mockAudioBuffer.duration);
expect(playback.currentState).toEqual(PlaybackState.Stopped); expect(playback.currentState).toEqual(PlaybackState.Stopped);
}); });

View file

@ -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<SeekBar>;
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(<SeekBar ref={seekBarRef} playback={playback} />);
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(<SeekBar disabled={true} playback={playback} />);
});
it("should render as expected", () => {
expect(renderResult.container).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,32 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SeekBar when rendering a SeekBar should render as expected 1`] = `
<div>
<input
class="mx_SeekBar"
max="1"
min="0"
step="0.001"
style="--fillTo: 0.0999840840362884;"
tabindex="0"
type="range"
value="0.0999840840362884"
/>
</div>
`;
exports[`SeekBar when rendering a disabled SeekBar should render as expected 1`] = `
<div>
<input
class="mx_SeekBar"
disabled=""
max="1"
min="0"
step="0.001"
style="--fillTo: 0;"
tabindex="0"
type="range"
value="0"
/>
</div>
`;

View file

@ -63,6 +63,9 @@ export const createTestPlayback = (): Playback => {
eventNames: eventEmitter.eventNames.bind(eventEmitter), eventNames: eventEmitter.eventNames.bind(eventEmitter),
prependListener: eventEmitter.prependListener.bind(eventEmitter), prependListener: eventEmitter.prependListener.bind(eventEmitter),
prependOnceListener: eventEmitter.prependOnceListener.bind(eventEmitter), prependOnceListener: eventEmitter.prependOnceListener.bind(eventEmitter),
liveData: new SimpleObservable<number[]>(),
durationSeconds: 31415,
timeSeconds: 3141,
} as PublicInterface<Playback> as Playback; } as PublicInterface<Playback> as Playback;
}; };