Extract PlaybackInterface (#9526)

pull/28788/head^2
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
}
export interface PlaybackInterface {
readonly liveData: SimpleObservable<number[]>;
readonly timeSeconds: number;
readonly durationSeconds: number;
skipTo(timeSeconds: number): Promise<void>;
}
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<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 {
this.state = event;
super.emit(event, ...args);

View File

@ -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 {
<SeekBar
playback={this.props.playback}
tabIndex={-1} // prevent tabbing into the bar
playbackPhase={this.state.playbackPhase}
disabled={this.state.playbackPhase === PlaybackState.Decoding}
ref={this.seekRef}
/>
<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 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<IProps> {
<SeekBar
playback={this.props.playback}
tabIndex={0} // allow keyboard users to fall into the seek bar
playbackPhase={this.state.playbackPhase}
disabled={this.state.playbackPhase === PlaybackState.Decoding}
ref={this.seekRef}
/>
</div>

View File

@ -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<IProps, IState> {
public static defaultProps = {
tabIndex: 0,
disabled: false,
};
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
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<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.
//
// 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<IProps, IState> {
value={this.state.percentage}
step={0.001}
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(),
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);
});

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),
prependListener: eventEmitter.prependListener.bind(eventEmitter),
prependOnceListener: eventEmitter.prependOnceListener.bind(eventEmitter),
liveData: new SimpleObservable<number[]>(),
durationSeconds: 31415,
timeSeconds: 3141,
} as PublicInterface<Playback> as Playback;
};