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 @@ +<svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg"> +<circle cx="5" cy="5" r="5" fill="#FF5B55"/> +</svg> 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<Optional<ChunkRecordedPayload>> { 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<React.SVGProps<SVGSVGElement>>; label: string; onClick: () => void; } export const VoiceBroadcastControl: React.FC<Props> = ({ + className = "", icon: Icon, label, onClick, }) => { return <AccessibleButton - className="mx_VoiceBroadcastControl" + className={classNames("mx_VoiceBroadcastControl", className)} onClick={onClick} aria-label={label} > 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<VoiceBroadcastRecordingPipProps> = ({ recording }) => { const { live, - sender, + recordingState, room, + sender, stopRecording, + toggleRecording, } = useVoiceBroadcastRecording(recording); + const toggleControl = recordingState === VoiceBroadcastInfoState.Paused + ? <VoiceBroadcastControl + className="mx_VoiceBroadcastControl-recording" + onClick={toggleRecording} + icon={RecordIcon} + label={_t("resume voice broadcast")} + /> + : <VoiceBroadcastControl onClick={toggleRecording} icon={PauseIcon} label={_t("pause voice broadcast")} />; + return <div className="mx_VoiceBroadcastRecordingPip" > @@ -46,6 +61,7 @@ export const VoiceBroadcastRecordingPip: React.FC<VoiceBroadcastRecordingPipProp /> <hr className="mx_VoiceBroadcastRecordingPip_divider" /> <div className="mx_VoiceBroadcastRecordingPip_controls"> + { toggleControl } <VoiceBroadcastControl icon={StopIcon} label="Stop Recording" diff --git a/src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx b/src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx index 341283c2ad..ed27119de1 100644 --- a/src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx +++ b/src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx @@ -52,23 +52,31 @@ export const useVoiceBroadcastRecording = (recording: VoiceBroadcastRecording) = const confirmed = await showStopBroadcastingDialog(); if (confirmed) { - recording.stop(); + await recording.stop(); } }; - const [live, setLive] = useState(recording.getState() === VoiceBroadcastInfoState.Started); + const [recordingState, setRecordingState] = useState(recording.getState()); useTypedEventEmitter( recording, VoiceBroadcastRecordingEvent.StateChanged, (state: VoiceBroadcastInfoState, _recording: VoiceBroadcastRecording) => { - 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<void> { + 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<void> { + // 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<void> { + if (this.state !== VoiceBroadcastInfoState.Paused) return; + + this.setState(VoiceBroadcastInfoState.Running); + await this.getRecorder().start(); + await this.sendInfoStateEvent(VoiceBroadcastInfoState.Running); + } + + public toggle = async (): Promise<void> => { + 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<void> { + private async sendInfoStateEvent(state: VoiceBroadcastInfoState): Promise<void> { // 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(<VoiceBroadcastRecordingPip recording={recording} />); + }; 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(<VoiceBroadcastRecordingPip recording={recording} />); + 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`] = ` <div> <div class="mx_VoiceBroadcastRecordingPip" @@ -29,7 +29,7 @@ exports[`VoiceBroadcastRecordingPip when rendering should create the expected re class="mx_Icon mx_Icon_16" /> <span> - @user:example.com + @userId:matrix.org </span> </div> </div> @@ -48,6 +48,89 @@ exports[`VoiceBroadcastRecordingPip when rendering should create the expected re <div class="mx_VoiceBroadcastRecordingPip_controls" > + <div + aria-label="resume voice broadcast" + class="mx_AccessibleButton mx_VoiceBroadcastControl mx_VoiceBroadcastControl-recording" + role="button" + tabindex="0" + > + <div + class="mx_Icon mx_Icon_16" + /> + </div> + <div + aria-label="Stop Recording" + class="mx_AccessibleButton mx_VoiceBroadcastControl" + role="button" + tabindex="0" + > + <div + class="mx_Icon mx_Icon_16" + /> + </div> + </div> + </div> +</div> +`; + +exports[`VoiceBroadcastRecordingPip when rendering a started recording should render as expected 1`] = ` +<div> + <div + class="mx_VoiceBroadcastRecordingPip" + > + <div + class="mx_VoiceBroadcastHeader" + > + <div + data-testid="room-avatar" + > + room avatar: + My room + </div> + <div + class="mx_VoiceBroadcastHeader_content" + > + <div + class="mx_VoiceBroadcastHeader_room" + > + My room + </div> + <div + class="mx_VoiceBroadcastHeader_line" + > + <div + class="mx_Icon mx_Icon_16" + /> + <span> + @userId:matrix.org + </span> + </div> + </div> + <div + class="mx_LiveBadge" + > + <div + class="mx_Icon mx_Icon_16" + /> + Live + </div> + </div> + <hr + class="mx_VoiceBroadcastRecordingPip_divider" + /> + <div + class="mx_VoiceBroadcastRecordingPip_controls" + > + <div + aria-label="pause voice broadcast" + class="mx_AccessibleButton mx_VoiceBroadcastControl" + role="button" + tabindex="0" + > + <div + class="mx_Icon mx_Icon_16" + /> + </div> <div aria-label="Stop Recording" class="mx_AccessibleButton mx_VoiceBroadcastControl" diff --git a/test/voice-broadcast/models/VoiceBroadcastRecording-test.ts b/test/voice-broadcast/models/VoiceBroadcastRecording-test.ts index 049c03c5a4..3e1965097b 100644 --- a/test/voice-broadcast/models/VoiceBroadcastRecording-test.ts +++ b/test/voice-broadcast/models/VoiceBroadcastRecording-test.ts @@ -92,6 +92,25 @@ describe("VoiceBroadcastRecording", () => { }); }; + 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", () => {