Extract PlaybackInterface (#9526)
This commit is contained in:
parent
1e65dcd0aa
commit
9096bd82d6
8 changed files with 182 additions and 13 deletions
|
@ -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);
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
/>;
|
/>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
106
test/components/views/audio_messages/SeekBar-test.tsx
Normal file
106
test/components/views/audio_messages/SeekBar-test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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>
|
||||||
|
`;
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue