From b7996a2e4980ccadfbb9f6d0535d5388afe060c1 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Thu, 20 Oct 2022 14:44:41 +0200 Subject: [PATCH] Implement pause voice broadcast recording (#9469) --- .../atoms/_VoiceBroadcastControl.pcss | 4 + .../_VoiceBroadcastRecordingPip.pcss | 2 +- res/img/element-icons/Record.svg | 3 + .../audio/VoiceBroadcastRecorder.ts | 3 + .../atoms/VoiceBroadcastControl.tsx | 5 +- .../molecules/VoiceBroadcastRecordingPip.tsx | 18 +++- .../hooks/useVoiceBroadcastRecording.tsx | 14 ++- .../models/VoiceBroadcastRecording.ts | 33 ++++++- .../VoiceBroadcastRecordingPip-test.tsx | 65 ++++++++++---- .../VoiceBroadcastRecordingPip-test.tsx.snap | 87 ++++++++++++++++++- .../models/VoiceBroadcastRecording-test.ts | 65 ++++++++++++++ 11 files changed, 272 insertions(+), 27 deletions(-) create mode 100644 res/img/element-icons/Record.svg diff --git a/res/css/voice-broadcast/atoms/_VoiceBroadcastControl.pcss b/res/css/voice-broadcast/atoms/_VoiceBroadcastControl.pcss index bf07157a68..f7cba04870 100644 --- a/res/css/voice-broadcast/atoms/_VoiceBroadcastControl.pcss +++ b/res/css/voice-broadcast/atoms/_VoiceBroadcastControl.pcss @@ -25,3 +25,7 @@ limitations under the License. margin-bottom: $spacing-8; width: 32px; } + +.mx_VoiceBroadcastControl-recording { + color: $alert; +} diff --git a/res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingPip.pcss b/res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingPip.pcss index b01b1b80db..11534a4797 100644 --- a/res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingPip.pcss +++ b/res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingPip.pcss @@ -31,5 +31,5 @@ limitations under the License. .mx_VoiceBroadcastRecordingPip_controls { display: flex; - justify-content: center; + justify-content: space-around; } diff --git a/res/img/element-icons/Record.svg b/res/img/element-icons/Record.svg new file mode 100644 index 0000000000..a16ce774b0 --- /dev/null +++ b/res/img/element-icons/Record.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts b/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts index 7f084f3f4a..f84da152ba 100644 --- a/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts +++ b/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts @@ -21,6 +21,7 @@ import { VoiceRecording } from "../../audio/VoiceRecording"; import SdkConfig, { DEFAULTS } from "../../SdkConfig"; import { concat } from "../../utils/arrays"; import { IDestroyable } from "../../utils/IDestroyable"; +import { Singleflight } from "../../utils/Singleflight"; export enum VoiceBroadcastRecorderEvent { ChunkRecorded = "chunk_recorded", @@ -65,6 +66,8 @@ export class VoiceBroadcastRecorder */ public async stop(): Promise> { await this.voiceRecording.stop(); + // forget about that call, so that we can stop it again later + Singleflight.forgetAllFor(this.voiceRecording); return this.extractChunk(); } diff --git a/src/voice-broadcast/components/atoms/VoiceBroadcastControl.tsx b/src/voice-broadcast/components/atoms/VoiceBroadcastControl.tsx index 238d138698..276282d198 100644 --- a/src/voice-broadcast/components/atoms/VoiceBroadcastControl.tsx +++ b/src/voice-broadcast/components/atoms/VoiceBroadcastControl.tsx @@ -14,23 +14,26 @@ See the License for the specific language governing permissions and limitations under the License. */ +import classNames from "classnames"; import React from "react"; import AccessibleButton from "../../../components/views/elements/AccessibleButton"; interface Props { + className?: string; icon: React.FC>; label: string; onClick: () => void; } export const VoiceBroadcastControl: React.FC = ({ + className = "", icon: Icon, label, onClick, }) => { return diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx index 7178f65965..57e291cae0 100644 --- a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx +++ b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx @@ -18,11 +18,15 @@ import React from "react"; import { VoiceBroadcastControl, + VoiceBroadcastInfoState, VoiceBroadcastRecording, } from "../.."; import { useVoiceBroadcastRecording } from "../../hooks/useVoiceBroadcastRecording"; import { VoiceBroadcastHeader } from "../atoms/VoiceBroadcastHeader"; import { Icon as StopIcon } from "../../../../res/img/element-icons/Stop.svg"; +import { Icon as PauseIcon } from "../../../../res/img/element-icons/pause.svg"; +import { Icon as RecordIcon } from "../../../../res/img/element-icons/Record.svg"; +import { _t } from "../../../languageHandler"; interface VoiceBroadcastRecordingPipProps { recording: VoiceBroadcastRecording; @@ -31,11 +35,22 @@ interface VoiceBroadcastRecordingPipProps { export const VoiceBroadcastRecordingPip: React.FC = ({ recording }) => { const { live, - sender, + recordingState, room, + sender, stopRecording, + toggleRecording, } = useVoiceBroadcastRecording(recording); + const toggleControl = recordingState === VoiceBroadcastInfoState.Paused + ? + : ; + return
@@ -46,6 +61,7 @@ export const VoiceBroadcastRecordingPip: React.FC
+ { toggleControl } { - setLive(state === VoiceBroadcastInfoState.Started); + setRecordingState(state); }, ); + const live = [ + VoiceBroadcastInfoState.Started, + VoiceBroadcastInfoState.Paused, + VoiceBroadcastInfoState.Running, + ].includes(recordingState); + return { live, + recordingState, room, sender: recording.infoEvent.sender, stopRecording, + toggleRecording: recording.toggle, }; }; diff --git a/src/voice-broadcast/models/VoiceBroadcastRecording.ts b/src/voice-broadcast/models/VoiceBroadcastRecording.ts index f7faa0876e..fccd9d5778 100644 --- a/src/voice-broadcast/models/VoiceBroadcastRecording.ts +++ b/src/voice-broadcast/models/VoiceBroadcastRecording.ts @@ -76,11 +76,38 @@ export class VoiceBroadcastRecording } public async stop(): Promise { + if (this.state === VoiceBroadcastInfoState.Stopped) return; + this.setState(VoiceBroadcastInfoState.Stopped); await this.stopRecorder(); - await this.sendStoppedStateEvent(); + await this.sendInfoStateEvent(VoiceBroadcastInfoState.Stopped); } + public async pause(): Promise { + // stopped or already paused recordings cannot be paused + if ([VoiceBroadcastInfoState.Stopped, VoiceBroadcastInfoState.Paused].includes(this.state)) return; + + this.setState(VoiceBroadcastInfoState.Paused); + await this.stopRecorder(); + await this.sendInfoStateEvent(VoiceBroadcastInfoState.Paused); + } + + public async resume(): Promise { + if (this.state !== VoiceBroadcastInfoState.Paused) return; + + this.setState(VoiceBroadcastInfoState.Running); + await this.getRecorder().start(); + await this.sendInfoStateEvent(VoiceBroadcastInfoState.Running); + } + + public toggle = async (): Promise => { + if (this.getState() === VoiceBroadcastInfoState.Paused) return this.resume(); + + if ([VoiceBroadcastInfoState.Started, VoiceBroadcastInfoState.Running].includes(this.getState())) { + return this.pause(); + } + }; + public getState(): VoiceBroadcastInfoState { return this.state; } @@ -162,14 +189,14 @@ export class VoiceBroadcastRecording await this.client.sendMessage(this.infoEvent.getRoomId(), content); } - private async sendStoppedStateEvent(): Promise { + private async sendInfoStateEvent(state: VoiceBroadcastInfoState): Promise { // TODO Michael W: add error handling for state event await this.client.sendStateEvent( this.infoEvent.getRoomId(), VoiceBroadcastInfoEventType, { device_id: this.client.getDeviceId(), - state: VoiceBroadcastInfoState.Stopped, + state, ["m.relates_to"]: { rel_type: RelationType.Reference, event_id: this.infoEvent.getId(), diff --git a/test/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip-test.tsx b/test/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip-test.tsx index 495d1f109f..8dd6c0a495 100644 --- a/test/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip-test.tsx +++ b/test/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip-test.tsx @@ -22,12 +22,12 @@ import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { sleep } from "matrix-js-sdk/src/utils"; import { - VoiceBroadcastInfoEventType, VoiceBroadcastInfoState, VoiceBroadcastRecording, VoiceBroadcastRecordingPip, } from "../../../../src/voice-broadcast"; -import { mkEvent, stubClient } from "../../../test-utils"; +import { stubClient } from "../../../test-utils"; +import { mkVoiceBroadcastInfoStateEvent } from "../../utils/test-utils"; // mock RoomAvatar, because it is doing too much fancy stuff jest.mock("../../../../src/components/views/avatars/RoomAvatar", () => ({ @@ -37,36 +37,49 @@ jest.mock("../../../../src/components/views/avatars/RoomAvatar", () => ({ }), })); +jest.mock("../../../../src/audio/VoiceRecording"); + describe("VoiceBroadcastRecordingPip", () => { - const userId = "@user:example.com"; const roomId = "!room:example.com"; let client: MatrixClient; let infoEvent: MatrixEvent; let recording: VoiceBroadcastRecording; + let renderResult: RenderResult; + + const renderPip = (state: VoiceBroadcastInfoState) => { + infoEvent = mkVoiceBroadcastInfoStateEvent(roomId, state, client.getUserId()); + recording = new VoiceBroadcastRecording(infoEvent, client); + + if (state === VoiceBroadcastInfoState.Paused) { + recording.pause(); + } + + renderResult = render(); + }; beforeAll(() => { client = stubClient(); - infoEvent = mkEvent({ - event: true, - type: VoiceBroadcastInfoEventType, - content: {}, - room: roomId, - user: userId, - }); - recording = new VoiceBroadcastRecording(infoEvent, client); }); - describe("when rendering", () => { - let renderResult: RenderResult; - + describe("when rendering a started recording", () => { beforeEach(() => { - renderResult = render(); + renderPip(VoiceBroadcastInfoState.Started); }); - it("should create the expected result", () => { + it("should render as expected", () => { expect(renderResult.container).toMatchSnapshot(); }); + describe("and clicking the pause button", () => { + beforeEach(async () => { + await userEvent.click(screen.getByLabelText("pause voice broadcast")); + }); + + it("should pause the recording", () => { + expect(recording.getState()).toBe(VoiceBroadcastInfoState.Paused); + }); + }); + describe("and clicking the stop button", () => { beforeEach(async () => { await userEvent.click(screen.getByLabelText("Stop Recording")); @@ -89,4 +102,24 @@ describe("VoiceBroadcastRecordingPip", () => { }); }); }); + + describe("when rendering a paused recording", () => { + beforeEach(() => { + renderPip(VoiceBroadcastInfoState.Paused); + }); + + it("should render as expected", () => { + expect(renderResult.container).toMatchSnapshot(); + }); + + describe("and clicking the resume button", () => { + beforeEach(async () => { + await userEvent.click(screen.getByLabelText("resume voice broadcast")); + }); + + it("should resume the recording", () => { + expect(recording.getState()).toBe(VoiceBroadcastInfoState.Running); + }); + }); + }); }); diff --git a/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastRecordingPip-test.tsx.snap b/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastRecordingPip-test.tsx.snap index 43f7d049ee..7f212d781b 100644 --- a/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastRecordingPip-test.tsx.snap +++ b/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastRecordingPip-test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`VoiceBroadcastRecordingPip when rendering should create the expected result 1`] = ` +exports[`VoiceBroadcastRecordingPip when rendering a paused recording should render as expected 1`] = `
- @user:example.com + @userId:matrix.org
@@ -48,6 +48,89 @@ exports[`VoiceBroadcastRecordingPip when rendering should create the expected re
+
+
+
+
+
+
+
+
+
+`; + +exports[`VoiceBroadcastRecordingPip when rendering a started recording should render as expected 1`] = ` +
+
+
+
+ room avatar: + My room +
+
+
+ My room +
+
+
+ + @userId:matrix.org + +
+
+
+
+ Live +
+
+
+
+
+
+
{ }); }; + const itShouldSendAnInfoEvent = (state: VoiceBroadcastInfoState) => { + it(`should send a ${state} info event`, () => { + expect(client.sendStateEvent).toHaveBeenCalledWith( + roomId, + VoiceBroadcastInfoEventType, + { + + device_id: client.getDeviceId(), + state, + ["m.relates_to"]: { + rel_type: RelationType.Reference, + event_id: infoEvent.getId(), + }, + }, + client.getUserId(), + ); + }); + }; + beforeEach(() => { client = stubClient(); room = mkStubRoom(roomId, "Test Room", client); @@ -355,6 +374,26 @@ describe("VoiceBroadcastRecording", () => { }); }); + describe.each([ + ["pause", async () => voiceBroadcastRecording.pause()], + ["toggle", async () => voiceBroadcastRecording.toggle()], + ])("and calling %s", (_case: string, action: Function) => { + beforeEach(async () => { + await action(); + }); + + itShouldBeInState(VoiceBroadcastInfoState.Paused); + itShouldSendAnInfoEvent(VoiceBroadcastInfoState.Paused); + + it("should stop the recorder", () => { + expect(mocked(voiceBroadcastRecorder.stop)).toHaveBeenCalled(); + }); + + it("should emit a paused state changed event", () => { + expect(onStateChanged).toHaveBeenCalledWith(VoiceBroadcastInfoState.Paused); + }); + }); + describe("and calling destroy", () => { beforeEach(() => { voiceBroadcastRecording.destroy(); @@ -370,6 +409,32 @@ describe("VoiceBroadcastRecording", () => { }); }); }); + + describe("and it is in paused state", () => { + beforeEach(async () => { + await voiceBroadcastRecording.pause(); + }); + + describe.each([ + ["resume", async () => voiceBroadcastRecording.resume()], + ["toggle", async () => voiceBroadcastRecording.toggle()], + ])("and calling %s", (_case: string, action: Function) => { + beforeEach(async () => { + await action(); + }); + + itShouldBeInState(VoiceBroadcastInfoState.Running); + itShouldSendAnInfoEvent(VoiceBroadcastInfoState.Running); + + it("should start the recorder", () => { + expect(mocked(voiceBroadcastRecorder.start)).toHaveBeenCalled(); + }); + + it("should emit a running state changed event", () => { + expect(onStateChanged).toHaveBeenCalledWith(VoiceBroadcastInfoState.Running); + }); + }); + }); }); describe("when created for a Voice Broadcast Info with a Stopped relation", () => {