Confirm listen to a broadcast while recording (#9831)

This commit is contained in:
Michael Weimann 2023-01-02 13:21:33 +01:00 committed by GitHub
parent 91e078d96b
commit 0f7a2ce6df
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 213 additions and 29 deletions

View file

@ -172,7 +172,7 @@ export class SdkContextClass {
public get voiceBroadcastPlaybacksStore(): VoiceBroadcastPlaybacksStore {
if (!this._VoiceBroadcastPlaybacksStore) {
this._VoiceBroadcastPlaybacksStore = new VoiceBroadcastPlaybacksStore();
this._VoiceBroadcastPlaybacksStore = new VoiceBroadcastPlaybacksStore(this.voiceBroadcastRecordingsStore);
}
return this._VoiceBroadcastPlaybacksStore;
}

View file

@ -659,6 +659,10 @@
"Stop live broadcasting?": "Stop live broadcasting?",
"Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.": "Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.",
"Yes, stop broadcast": "Yes, stop broadcast",
"Listen to live broadcast?": "Listen to live broadcast?",
"If you start listening to this live broadcast, your current live broadcast recording will be ended.": "If you start listening to this live broadcast, your current live broadcast recording will be ended.",
"Yes, end my recording": "Yes, end my recording",
"No": "No",
"30s backward": "30s backward",
"30s forward": "30s forward",
"Go live": "Go live",
@ -815,7 +819,6 @@
"Learn more": "Learn more",
"Share anonymous data to help us identify issues. Nothing personal. No third parties. <LearnMoreLink>Learn More</LearnMoreLink>": "Share anonymous data to help us identify issues. Nothing personal. No third parties. <LearnMoreLink>Learn More</LearnMoreLink>",
"Yes": "Yes",
"No": "No",
"Help improve %(analyticsOwner)s": "Help improve %(analyticsOwner)s",
"You have unverified sessions": "You have unverified sessions",
"Review to ensure your account is safe": "Review to ensure your account is safe",

View file

@ -0,0 +1,56 @@
/*
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 { defer } from "matrix-js-sdk/src/utils";
import React from "react";
import BaseDialog from "../../../components/views/dialogs/BaseDialog";
import DialogButtons from "../../../components/views/elements/DialogButtons";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
interface Props {
onFinished: (confirmed: boolean) => void;
}
export const ConfirmListenBroadcastStopCurrentDialog: React.FC<Props> = ({ onFinished }) => {
return (
<BaseDialog title={_t("Listen to live broadcast?")} hasCancel={true} onFinished={onFinished}>
<p>
{_t(
"If you start listening to this live broadcast, " +
"your current live broadcast recording will be ended.",
)}
</p>
<DialogButtons
onPrimaryButtonClick={() => onFinished(true)}
primaryButton={_t("Yes, end my recording")}
cancelButton={_t("No")}
onCancel={() => onFinished(false)}
/>
</BaseDialog>
);
};
export const showConfirmListenBroadcastStopCurrentDialog = async (): Promise<boolean> => {
const { promise, resolve } = defer<boolean>();
Modal.createDialog(ConfirmListenBroadcastStopCurrentDialog, {
onFinished: resolve,
});
return promise;
};

View file

@ -30,6 +30,7 @@ export * from "./components/atoms/VoiceBroadcastControl";
export * from "./components/atoms/VoiceBroadcastHeader";
export * from "./components/atoms/VoiceBroadcastPlaybackControl";
export * from "./components/atoms/VoiceBroadcastRoomSubtitle";
export * from "./components/molecules/ConfirmListeBroadcastStopCurrent";
export * from "./components/molecules/VoiceBroadcastPlaybackBody";
export * from "./components/molecules/VoiceBroadcastSmallPlaybackBody";
export * from "./components/molecules/VoiceBroadcastPreRecordingPip";

View file

@ -36,6 +36,8 @@ import {
VoiceBroadcastInfoEventType,
VoiceBroadcastInfoState,
VoiceBroadcastInfoEventContent,
VoiceBroadcastRecordingsStore,
showConfirmListenBroadcastStopCurrentDialog,
} from "..";
import { RelationsHelper, RelationsHelperEvent } from "../../events/RelationsHelper";
import { VoiceBroadcastChunkEvents } from "../utils/VoiceBroadcastChunkEvents";
@ -86,7 +88,7 @@ export class VoiceBroadcastPlayback
public readonly liveData = new SimpleObservable<number[]>();
private liveness: VoiceBroadcastLiveness = "not-live";
// set vial addInfoEvent() in constructor
// set via addInfoEvent() in constructor
private infoState!: VoiceBroadcastInfoState;
private lastInfoEvent!: MatrixEvent;
@ -94,7 +96,11 @@ export class VoiceBroadcastPlayback
private chunkRelationHelper!: RelationsHelper;
private infoRelationHelper!: RelationsHelper;
public constructor(public readonly infoEvent: MatrixEvent, private client: MatrixClient) {
public constructor(
public readonly infoEvent: MatrixEvent,
private client: MatrixClient,
private recordings: VoiceBroadcastRecordingsStore,
) {
super();
this.addInfoEvent(this.infoEvent);
this.infoEvent.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
@ -399,6 +405,21 @@ export class VoiceBroadcastPlayback
}
public async start(): Promise<void> {
if (this.state === VoiceBroadcastPlaybackState.Playing) return;
const currentRecording = this.recordings.getCurrent();
if (currentRecording && currentRecording.getState() !== VoiceBroadcastInfoState.Stopped) {
const shouldStopRecording = await showConfirmListenBroadcastStopCurrentDialog();
if (!shouldStopRecording) {
// keep recording
return;
}
await this.recordings.getCurrent()?.stop();
}
const chunkEvents = this.chunkEvents.getEvents();
const toPlay =

View file

@ -17,7 +17,12 @@ limitations under the License.
import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter";
import { VoiceBroadcastPlayback, VoiceBroadcastPlaybackEvent, VoiceBroadcastPlaybackState } from "..";
import {
VoiceBroadcastPlayback,
VoiceBroadcastPlaybackEvent,
VoiceBroadcastPlaybackState,
VoiceBroadcastRecordingsStore,
} from "..";
import { IDestroyable } from "../../utils/IDestroyable";
export enum VoiceBroadcastPlaybacksStoreEvent {
@ -42,7 +47,7 @@ export class VoiceBroadcastPlaybacksStore
/** Playbacks indexed by their info event id. */
private playbacks = new Map<string, VoiceBroadcastPlayback>();
public constructor() {
public constructor(private recordings: VoiceBroadcastRecordingsStore) {
super();
}
@ -69,7 +74,7 @@ export class VoiceBroadcastPlaybacksStore
const infoEventId = infoEvent.getId();
if (!this.playbacks.has(infoEventId)) {
this.addPlayback(new VoiceBroadcastPlayback(infoEvent, client));
this.addPlayback(new VoiceBroadcastPlayback(infoEvent, client, this.recordings));
}
return this.playbacks.get(infoEventId);

View file

@ -406,6 +406,7 @@ describe("LegacyCallHandler", () => {
"d42",
),
MatrixClientPeg.get(),
SdkContextClass.instance.voiceBroadcastRecordingsStore,
);
SdkContextClass.instance.voiceBroadcastPlaybacksStore.setCurrent(voiceBroadcastPlayback);
jest.spyOn(voiceBroadcastPlayback, "pause").mockImplementation();

View file

@ -112,7 +112,7 @@ describe("PipView", () => {
sdkContext = new TestSdkContext();
voiceBroadcastRecordingsStore = new VoiceBroadcastRecordingsStore();
voiceBroadcastPreRecordingStore = new VoiceBroadcastPreRecordingStore();
voiceBroadcastPlaybacksStore = new VoiceBroadcastPlaybacksStore();
voiceBroadcastPlaybacksStore = new VoiceBroadcastPlaybacksStore(voiceBroadcastRecordingsStore);
sdkContext.client = client;
sdkContext._VoiceBroadcastRecordingsStore = voiceBroadcastRecordingsStore;
sdkContext._VoiceBroadcastPreRecordingStore = voiceBroadcastPreRecordingStore;

View file

@ -132,7 +132,7 @@ describe("RoomViewStore", function () {
stores._SlidingSyncManager = slidingSyncManager;
stores._PosthogAnalytics = new MockPosthogAnalytics();
stores._SpaceStore = new MockSpaceStore();
stores._VoiceBroadcastPlaybacksStore = new VoiceBroadcastPlaybacksStore();
stores._VoiceBroadcastPlaybacksStore = new VoiceBroadcastPlaybacksStore(stores.voiceBroadcastRecordingsStore);
roomViewStore = new RoomViewStore(dis, stores);
stores._RoomViewStore = roomViewStore;
});
@ -271,6 +271,7 @@ describe("RoomViewStore", function () {
"d42",
),
mockClient,
stores.voiceBroadcastRecordingsStore,
);
stores.voiceBroadcastPlaybacksStore.setCurrent(voiceBroadcastPlayback);
jest.spyOn(voiceBroadcastPlayback, "pause").mockImplementation();

View file

@ -26,6 +26,7 @@ import {
VoiceBroadcastRecording,
VoiceBroadcastPlaybackBody,
VoiceBroadcastPlayback,
VoiceBroadcastRecordingsStore,
} from "../../../src/voice-broadcast";
import { stubClient, wrapInSdkContext } from "../../test-utils";
import { mkVoiceBroadcastInfoStateEvent } from "../utils/test-utils";
@ -91,7 +92,7 @@ describe("VoiceBroadcastBody", () => {
);
room.addEventsToTimeline([infoEvent], true, room.getLiveTimeline());
testRecording = new VoiceBroadcastRecording(infoEvent, client);
testPlayback = new VoiceBroadcastPlayback(infoEvent, client);
testPlayback = new VoiceBroadcastPlayback(infoEvent, client, new VoiceBroadcastRecordingsStore());
mocked(VoiceBroadcastRecordingBody).mockImplementation(({ recording }): ReactElement | null => {
if (testRecording === recording) {
return <div data-testid="voice-broadcast-recording-body" />;

View file

@ -32,6 +32,7 @@ import { stubClient } from "../../../test-utils";
import { mkVoiceBroadcastInfoStateEvent } from "../../utils/test-utils";
import dis from "../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../src/dispatcher/actions";
import { SdkContextClass } from "../../../../src/contexts/SDKContext";
jest.mock("../../../../src/dispatcher/dispatcher");
@ -66,7 +67,11 @@ describe("VoiceBroadcastPlaybackBody", () => {
});
beforeEach(() => {
playback = new VoiceBroadcastPlayback(infoEvent, client);
playback = new VoiceBroadcastPlayback(
infoEvent,
client,
SdkContextClass.instance.voiceBroadcastRecordingsStore,
);
jest.spyOn(playback, "toggle").mockImplementation(() => Promise.resolve());
jest.spyOn(playback, "getLiveness");
jest.spyOn(playback, "getState");

View file

@ -66,8 +66,8 @@ describe("VoiceBroadcastPreRecordingPip", () => {
client = stubClient();
room = new Room("!room@example.com", client, client.getUserId() || "");
sender = new RoomMember(room.roomId, client.getUserId() || "");
playbacksStore = new VoiceBroadcastPlaybacksStore();
recordingsStore = new VoiceBroadcastRecordingsStore();
playbacksStore = new VoiceBroadcastPlaybacksStore(recordingsStore);
mocked(requestMediaPermissions).mockResolvedValue({
getTracks: (): Array<MediaStreamTrack> => [],
} as unknown as MediaStream);

View file

@ -29,6 +29,7 @@ import {
} from "../../../../src/voice-broadcast";
import { stubClient } from "../../../test-utils";
import { mkVoiceBroadcastInfoStateEvent } from "../../utils/test-utils";
import { SdkContextClass } from "../../../../src/contexts/SDKContext";
// mock RoomAvatar, because it is doing too much fancy stuff
jest.mock("../../../../src/components/views/avatars/RoomAvatar", () => ({
@ -60,7 +61,11 @@ describe("<VoiceBroadcastSmallPlaybackBody />", () => {
});
beforeEach(() => {
playback = new VoiceBroadcastPlayback(infoEvent, client);
playback = new VoiceBroadcastPlayback(
infoEvent,
client,
SdkContextClass.instance.voiceBroadcastRecordingsStore,
);
jest.spyOn(playback, "toggle").mockImplementation(() => Promise.resolve());
jest.spyOn(playback, "getLiveness");
jest.spyOn(playback, "getState");

View file

@ -15,10 +15,13 @@ limitations under the License.
*/
import { mocked } from "jest-mock";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { Playback, PlaybackState } from "../../../src/audio/Playback";
import { PlaybackManager } from "../../../src/audio/PlaybackManager";
import { SdkContextClass } from "../../../src/contexts/SDKContext";
import { MediaEventHelper } from "../../../src/utils/MediaEventHelper";
import {
VoiceBroadcastInfoState,
@ -26,6 +29,7 @@ import {
VoiceBroadcastPlayback,
VoiceBroadcastPlaybackEvent,
VoiceBroadcastPlaybackState,
VoiceBroadcastRecording,
} from "../../../src/voice-broadcast";
import { flushPromises, stubClient } from "../../test-utils";
import { createTestPlayback } from "../../test-utils/audio";
@ -65,6 +69,17 @@ describe("VoiceBroadcastPlayback", () => {
let chunk2Playback: Playback;
let chunk3Playback: Playback;
const queryConfirmListeningDialog = () => {
return screen.queryByText(
"If you start listening to this live broadcast, your current live broadcast recording will be ended.",
);
};
const waitForDialog = async () => {
await flushPromises();
await flushPromises();
};
const itShouldSetTheStateTo = (state: VoiceBroadcastPlaybackState) => {
it(`should set the state to ${state}`, () => {
expect(playback.getState()).toBe(state);
@ -119,7 +134,11 @@ describe("VoiceBroadcastPlayback", () => {
};
const mkPlayback = async () => {
const playback = new VoiceBroadcastPlayback(infoEvent, client);
const playback = new VoiceBroadcastPlayback(
infoEvent,
client,
SdkContextClass.instance.voiceBroadcastRecordingsStore,
);
jest.spyOn(playback, "removeAllListeners");
jest.spyOn(playback, "destroy");
playback.on(VoiceBroadcastPlaybackEvent.StateChanged, onStateChanged);
@ -178,7 +197,11 @@ describe("VoiceBroadcastPlayback", () => {
onStateChanged = jest.fn();
});
afterEach(() => {
afterEach(async () => {
SdkContextClass.instance.voiceBroadcastPlaybacksStore.getCurrent()?.stop();
SdkContextClass.instance.voiceBroadcastPlaybacksStore.clearCurrent();
await SdkContextClass.instance.voiceBroadcastRecordingsStore.getCurrent()?.stop();
SdkContextClass.instance.voiceBroadcastRecordingsStore.clearCurrent();
playback.destroy();
});
@ -347,6 +370,59 @@ describe("VoiceBroadcastPlayback", () => {
});
});
});
describe("and currently recording a broadcast", () => {
let recording: VoiceBroadcastRecording;
beforeEach(async () => {
recording = new VoiceBroadcastRecording(
mkVoiceBroadcastInfoStateEvent(
roomId,
VoiceBroadcastInfoState.Started,
client.getSafeUserId(),
client.deviceId,
),
client,
);
jest.spyOn(recording, "stop");
SdkContextClass.instance.voiceBroadcastRecordingsStore.setCurrent(recording);
playback.start();
await waitForDialog();
});
it("should display a confirm modal", () => {
expect(queryConfirmListeningDialog()).toBeInTheDocument();
});
describe("when confirming the dialog", () => {
beforeEach(async () => {
await userEvent.click(screen.getByText("Yes, end my recording"));
});
it("should stop the recording", () => {
expect(recording.stop).toHaveBeenCalled();
expect(SdkContextClass.instance.voiceBroadcastRecordingsStore.getCurrent()).toBeNull();
});
it("should not start the playback", () => {
expect(playback.getState()).toBe(VoiceBroadcastPlaybackState.Playing);
});
});
describe("when not confirming the dialog", () => {
beforeEach(async () => {
await userEvent.click(screen.getByText("No"));
});
it("should not stop the recording", () => {
expect(recording.stop).not.toHaveBeenCalled();
});
it("should start the playback", () => {
expect(playback.getState()).toBe(VoiceBroadcastPlaybackState.Stopped);
});
});
});
});
describe("when there is a stopped voice broadcast", () => {
@ -375,6 +451,12 @@ describe("VoiceBroadcastPlayback", () => {
expect(chunk2Playback.play).not.toHaveBeenCalled();
});
describe("and calling start again", () => {
it("should not play the first chunk a second time", () => {
expect(chunk1Playback.play).toHaveBeenCalledTimes(1);
});
});
describe("and the chunk playback progresses", () => {
beforeEach(() => {
chunk1Playback.clockInfo.liveData.update([11]);

View file

@ -40,8 +40,8 @@ describe("VoiceBroadcastPreRecording", () => {
client = stubClient();
room = new Room(roomId, client, client.getUserId() || "");
sender = new RoomMember(roomId, client.getUserId() || "");
playbacksStore = new VoiceBroadcastPlaybacksStore();
recordingsStore = new VoiceBroadcastRecordingsStore();
playbacksStore = new VoiceBroadcastPlaybacksStore(recordingsStore);
});
beforeEach(() => {

View file

@ -24,6 +24,7 @@ import {
VoiceBroadcastPlaybacksStore,
VoiceBroadcastPlaybacksStoreEvent,
VoiceBroadcastPlaybackState,
VoiceBroadcastRecordingsStore,
} from "../../../src/voice-broadcast";
import { mkStubRoom, stubClient } from "../../test-utils";
import { mkVoiceBroadcastInfoStateEvent } from "../utils/test-utils";
@ -59,12 +60,13 @@ describe("VoiceBroadcastPlaybacksStore", () => {
infoEvent1 = mkVoiceBroadcastInfoStateEvent(roomId, VoiceBroadcastInfoState.Started, userId, deviceId);
infoEvent2 = mkVoiceBroadcastInfoStateEvent(roomId, VoiceBroadcastInfoState.Started, userId, deviceId);
playback1 = new VoiceBroadcastPlayback(infoEvent1, client);
const recordings = new VoiceBroadcastRecordingsStore();
playback1 = new VoiceBroadcastPlayback(infoEvent1, client, recordings);
jest.spyOn(playback1, "off");
playback2 = new VoiceBroadcastPlayback(infoEvent2, client);
playback2 = new VoiceBroadcastPlayback(infoEvent2, client, recordings);
jest.spyOn(playback2, "off");
playbacks = new VoiceBroadcastPlaybacksStore();
playbacks = new VoiceBroadcastPlaybacksStore(recordings);
jest.spyOn(playbacks, "removeAllListeners");
onCurrentChanged = jest.fn();
playbacks.on(VoiceBroadcastPlaybacksStoreEvent.CurrentChanged, onCurrentChanged);

View file

@ -25,8 +25,6 @@ import {
} from "../../../src/voice-broadcast";
import { stubClient } from "../../test-utils";
jest.mock("../../../src/voice-broadcast/stores/VoiceBroadcastRecordingsStore");
describe("VoiceBroadcastPreRecordingStore", () => {
const roomId = "!room:example.com";
let client: MatrixClient;
@ -41,8 +39,8 @@ describe("VoiceBroadcastPreRecordingStore", () => {
client = stubClient();
room = new Room(roomId, client, client.getUserId() || "");
sender = new RoomMember(roomId, client.getUserId() || "");
playbacksStore = new VoiceBroadcastPlaybacksStore();
recordingsStore = new VoiceBroadcastRecordingsStore();
playbacksStore = new VoiceBroadcastPlaybacksStore(recordingsStore);
});
beforeEach(() => {

View file

@ -20,6 +20,7 @@ import {
VoiceBroadcastInfoState,
VoiceBroadcastPlayback,
VoiceBroadcastPlaybacksStore,
VoiceBroadcastRecordingsStore,
} from "../../../src/voice-broadcast";
import { pauseNonLiveBroadcastFromOtherRoom } from "../../../src/voice-broadcast/utils/pauseNonLiveBroadcastFromOtherRoom";
import { stubClient } from "../../test-utils";
@ -32,6 +33,7 @@ describe("pauseNonLiveBroadcastFromOtherRoom", () => {
let client: MatrixClient;
let playback: VoiceBroadcastPlayback;
let playbacks: VoiceBroadcastPlaybacksStore;
let recordings: VoiceBroadcastRecordingsStore;
const mkPlayback = (infoState: VoiceBroadcastInfoState, roomId: string): VoiceBroadcastPlayback => {
const infoEvent = mkVoiceBroadcastInfoStateEvent(
@ -40,7 +42,7 @@ describe("pauseNonLiveBroadcastFromOtherRoom", () => {
client.getSafeUserId(),
client.getDeviceId()!,
);
const playback = new VoiceBroadcastPlayback(infoEvent, client);
const playback = new VoiceBroadcastPlayback(infoEvent, client, recordings);
jest.spyOn(playback, "pause");
playbacks.setCurrent(playback);
return playback;
@ -49,7 +51,8 @@ describe("pauseNonLiveBroadcastFromOtherRoom", () => {
beforeEach(() => {
client = stubClient();
room = new Room(roomId, client, client.getSafeUserId());
playbacks = new VoiceBroadcastPlaybacksStore();
recordings = new VoiceBroadcastRecordingsStore();
playbacks = new VoiceBroadcastPlaybacksStore(recordings);
jest.spyOn(playbacks, "clearCurrent");
});

View file

@ -80,10 +80,10 @@ describe("setUpVoiceBroadcastPreRecording", () => {
);
preRecording = null;
preRecordingStore = new VoiceBroadcastPreRecordingStore();
playback = new VoiceBroadcastPlayback(infoEvent, client);
jest.spyOn(playback, "pause");
playbacksStore = new VoiceBroadcastPlaybacksStore();
recordingsStore = new VoiceBroadcastRecordingsStore();
playback = new VoiceBroadcastPlayback(infoEvent, client, recordingsStore);
jest.spyOn(playback, "pause");
playbacksStore = new VoiceBroadcastPlaybacksStore(recordingsStore);
});
describe("when the preconditions fail", () => {

View file

@ -84,7 +84,7 @@ describe("startNewVoiceBroadcastRecording", () => {
skey: "",
});
playbacksStore = new VoiceBroadcastPlaybacksStore();
playbacksStore = new VoiceBroadcastPlaybacksStore(recordingsStore);
recordingsStore = {
setCurrent: jest.fn(),
getCurrent: jest.fn(),
@ -112,7 +112,7 @@ describe("startNewVoiceBroadcastRecording", () => {
let playback: VoiceBroadcastPlayback;
beforeEach(() => {
playback = new VoiceBroadcastPlayback(infoEvent, client);
playback = new VoiceBroadcastPlayback(infoEvent, client, recordingsStore);
jest.spyOn(playback, "pause");
playbacksStore.setCurrent(playback);
});